Skip to content

Commit 1248845

Browse files
authored
fix metadata arglists for python defaults (#285)
* fix(metadata): preserve pyarglists defaults * fix(metadata): add python import arglists * test(metadata): cover python arglist docs * 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 9c4b683 commit 1248845

5 files changed

Lines changed: 249 additions & 12 deletions

File tree

src/libpython_clj2/metadata.clj

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919

2020
(def builtins (import-module "builtins"))
21+
(def py-str (get-attr builtins "str"))
2122
(def inspect (import-module "inspect"))
2223
(def argspec (get-attr inspect "getfullargspec"))
2324
(def py-source (get-attr inspect "getsource"))
@@ -68,16 +69,49 @@
6869
(catch Exception _
6970
nil)))
7071

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+
84+
(defn- py-default->jvm [x]
85+
(let [jvm-val (->jvm 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)
89+
jvm-val)))
90+
91+
(defn- py-defaults->jvm [defaults]
92+
(when (->jvm defaults)
93+
(->> defaults
94+
(map py-default->jvm)
95+
(into []))))
96+
97+
(defn- py-kwonlydefaults->jvm [kwonlydefaults]
98+
(when (->jvm kwonlydefaults)
99+
(->> (call-attr kwonlydefaults "items")
100+
(map (fn [entry]
101+
(let [[k v] (seq entry)]
102+
[(->jvm k) (py-default->jvm v)])))
103+
(into {}))))
104+
71105
(defn py-fn-argspec [f]
72106
(if-let [spec (try (when-not (pyclass? f)
73107
(argspec f))
74108
(catch Throwable e nil))]
75109
{:args (->jvm (get-attr spec "args"))
76110
:varargs (->jvm (get-attr spec "varargs"))
77111
:varkw (->jvm (get-attr spec "varkw"))
78-
:defaults (->jvm (get-attr spec "defaults"))
112+
:defaults (py-defaults->jvm (get-attr spec "defaults"))
79113
:kwonlyargs (->jvm (get-attr spec "kwonlyargs"))
80-
:kwonlydefaults (->jvm (get-attr spec "kwonlydefaults"))
114+
:kwonlydefaults (py-kwonlydefaults->jvm (get-attr spec "kwonlydefaults"))
81115
:annotations (->jvm (get-attr spec "annotations"))}
82116
(py-fn-argspec (get-attr f "__init__"))))
83117

@@ -132,22 +166,16 @@
132166
(map symbol)
133167
(into []))
134168

135-
;;These sometimes have actual python symbols in them so we can't use them
136-
;; or-map (->> (concat
137-
;; (interleave kw-default-args defaults)
138-
;; (flatten (seq kwonlydefaults)))
139-
;; (partition-all 2)
140-
;; (map vec)
141-
;; (map (fn [[k v]] [(symbol k) v]))
142-
;; (into {}))
169+
;; Preserve the default values that inspect returned. These may be nil
170+
;; or non-keyword JVM representations of Python values.
143171
as-varkw (when (not (nil? varkw))
144172
{:as (symbol varkw)})
145173
default-map (->> (concat
146174
(interleave kw-default-args defaults)
147175
(flatten (seq kwonlydefaults)))
148176
(partition-all 2)
149177
(map vec)
150-
(map (fn [[k v]] [(symbol k) (keyword k)]))
178+
(map (fn [[k v]] [(symbol k) v]))
151179
(into {}))
152180

153181
kwargs-map (merge default-map

src/libpython_clj2/python.clj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,14 @@ user> (py/py. np linspace 2 3 :num 10)
381381
#'~varname))
382382

383383

384+
(defn ^:no-doc py-var-metadata [var-name var-data]
385+
(try
386+
(let [metadata-fn (requiring-resolve 'libpython-clj2.metadata/py-fn-metadata)]
387+
(select-keys (metadata-fn var-name var-data {}) [:doc :arglists]))
388+
(catch Throwable _
389+
{:doc (get-attr var-data "__doc__")})))
390+
391+
384392
(defmacro from-import
385393
"Support for the from a import b,c style of importing modules and symbols in python.
386394
Documentation is included."
@@ -390,7 +398,7 @@ user> (py/py. np linspace 2 3 :num 10)
390398
~@(map (fn [varname]
391399
`(let [~'var-data (get-attr ~'mod-data ~(name varname))]
392400
(def ~varname ~'var-data)
393-
(alter-meta! #'~varname assoc :doc (get-attr ~'var-data "__doc__"))
401+
(alter-meta! #'~varname merge (py-var-metadata ~(name varname) ~'var-data))
394402
#'~varname))
395403
(concat [item] args)))))
396404

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
(ns libpython-clj2.metadata-test
2+
(:require [clojure.test :refer :all]
3+
[clojure.string :as str]
4+
[libpython-clj2.python :as py]
5+
[libpython-clj2.metadata :as metadata]))
6+
7+
(deftest pyarglists-preserves-default-values
8+
(let [argspec {:args ["top" "topdown" "onerror"]
9+
:varargs nil
10+
:varkw nil
11+
:defaults ["." true nil]
12+
:kwonlyargs ["follow_symlinks" "dir_fd"]
13+
:kwonlydefaults (array-map "follow_symlinks" false
14+
"dir_fd" nil)}]
15+
(is (= '([& [{top "."
16+
topdown true
17+
onerror nil
18+
follow_symlinks false
19+
dir_fd nil}]]
20+
[& [{top "."
21+
topdown true
22+
follow_symlinks false
23+
dir_fd nil}]]
24+
[& [{top "."
25+
follow_symlinks false
26+
dir_fd nil}]]
27+
[& [{follow_symlinks false
28+
dir_fd nil}]])
29+
(metadata/pyarglists argspec)))))
30+
31+
(deftest py-fn-argspec-stringifies-python-object-defaults
32+
(let [testcode (py/import-module "testcode")
33+
default-type-fn (py/get-attr testcode "default_type_fn")]
34+
(is (= '([& [{dtype "<class 'int'>"}]]
35+
[])
36+
(-> default-type-fn
37+
metadata/py-fn-argspec
38+
metadata/pyarglists)))))
39+
40+
(deftest py-fn-argspec-stringifies-kwonly-python-object-defaults
41+
(let [testcode (py/import-module "testcode")
42+
kw-default-type-fn (py/get-attr testcode "kw_default_type_fn")]
43+
(is (= '([& [{dtype "<class 'int'>"}]])
44+
(-> kw-default-type-fn
45+
metadata/py-fn-argspec
46+
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)))))

test/libpython_clj2/python_test.clj

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
[tech.v3.datatype.ffi :as dt-ffi]
99
[tech.v3.tensor :as dtt]
1010
[clojure.test :refer :all]
11+
[clojure.repl :refer [doc]]
1112
libpython-clj2.python.bridge-as-python)
1213
(:import [java.io StringWriter]
1314
[java.util Map List]
@@ -153,6 +154,31 @@
153154
a)
154155
vec)))))
155156

157+
(py/from-import testcode defaults_fn)
158+
159+
(deftest from-import-adds-arglists-metadata
160+
(is (= '([& [{top "."
161+
topdown true
162+
onerror nil
163+
follow_symlinks false
164+
dir_fd nil}]]
165+
[& [{top "."
166+
topdown true
167+
follow_symlinks false
168+
dir_fd nil}]]
169+
[& [{top "."
170+
follow_symlinks false
171+
dir_fd nil}]]
172+
[& [{follow_symlinks false
173+
dir_fd nil}]])
174+
(:arglists (meta #'defaults_fn)))))
175+
176+
(deftest from-import-doc-renders-arglists
177+
(let [doc-output (with-out-str (doc defaults_fn))]
178+
(is (re-find #"defaults_fn" doc-output))
179+
(is (re-find #"\[\[& \[\{top \"\.\"" doc-output))
180+
(is (re-find #"Function with Python defaults for metadata tests\." doc-output))))
181+
156182
(deftest aspy-iter
157183
(let [testcode-module (py/import-module "testcode")]
158184
(is (= [1 2 3 4 5]

testcode/__init__.py

Lines changed: 83 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
@@ -49,9 +52,89 @@ def complex_fn(a, b, c: str = 5, *args, d=10, **kwargs):
4952
return {"a": a, "b": b, "c": c, "args": args, "d": d, "kwargs": kwargs}
5053

5154

55+
def defaults_fn(top=".", topdown=True, onerror=None, *, follow_symlinks=False, dir_fd=None):
56+
"""Function with Python defaults for metadata tests."""
57+
return top, topdown, onerror, follow_symlinks, dir_fd
58+
59+
60+
def default_type_fn(dtype=int):
61+
"""Function with a Python object default for metadata tests."""
62+
return dtype
63+
64+
65+
def kw_default_type_fn(*, dtype=int):
66+
"""Function with a keyword-only Python object default for metadata tests."""
67+
return dtype
68+
69+
5270
complex_fn_testcases = {
5371
"complex_fn(1, 2, c=10, d=10, e=10)": complex_fn(1, 2, c=10, d=10, e=10),
5472
"complex_fn(1, 2, 10, 11, 12, d=10, e=10)": complex_fn(
5573
1, 2, 10, 11, 12, d=10, e=10
5674
),
5775
}
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)