Skip to content

Commit 4dcb8fc

Browse files
authored
Add basilisp.reflect namespace for Python runtime reflection (#838)
Fixes #837
1 parent bd8649b commit 4dcb8fc

4 files changed

Lines changed: 345 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
* Added `basilisp.csv` namespace (#753)
10+
* Added `basilisp.reflect` namespace for Python VM runtime reflection (#837)
1011

1112
### Changed
1213
* Add `test` and `tests` directories to `PYTHONPATH` automatically during `basilisp test` CLI invocations (#1069)

docs/api/reflect.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
basilisp.reflect
2+
================
3+
4+
.. toctree::
5+
:maxdepth: 2
6+
:caption: Contents:
7+
8+
.. autonamespace:: basilisp.reflect
9+
:members:
10+
:undoc-members:

src/basilisp/reflect.lpy

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
(ns basilisp.reflect
2+
"Runtime reflection of Python objects."
3+
(:import importlib
4+
inspect
5+
types))
6+
7+
;;;;;;;;;;;;;;;;;;;;;
8+
;; Type References ;;
9+
;;;;;;;;;;;;;;;;;;;;;
10+
11+
(defprotocol TypeReference
12+
(typename [this]
13+
"Return a canonical name for the type named by ``this``."))
14+
15+
(extend-protocol TypeReference
16+
types/ModuleType
17+
(typename [this]
18+
(python/getattr this "__name__"))
19+
python/type
20+
(typename [this]
21+
(str (python/getattr this "__module__") "." (python/getattr this "__qualname__"))))
22+
23+
;;;;;;;;;;;;;;;;;;;;;;;
24+
;; Types and Modules ;;
25+
;;;;;;;;;;;;;;;;;;;;;;;
26+
27+
(defprotocol Reflectable
28+
(reflect* [this]
29+
"Reflect on ``this`` and return a map describing the object."))
30+
31+
(defn ^:private qualname->sym
32+
"Canonicalize a Python ``__qualname__`` as a symbol."
33+
[qualname]
34+
(let [[namespace-or-name maybe-name] (.rsplit qualname "." 1)]
35+
(if (nil? maybe-name)
36+
(symbol namespace-or-name)
37+
(symbol namespace-or-name maybe-name))))
38+
39+
(defn ^:private members->map
40+
"Given a seq of 2-tuples of ``member-name, member``, return a mapping of the
41+
member name converted to a symbol and the member."
42+
[m]
43+
(into {}
44+
(map (fn [[member-name member]]
45+
[(symbol member-name) (py->lisp member)]))
46+
m))
47+
48+
(defn ^:private ^:inline py-property?
49+
"Return ``true`` if this object is an instance of a Python ``property``."
50+
[o]
51+
(instance? python/property o))
52+
53+
(def ^:private method-like?
54+
"Predicate for determining if a class member can be treated similar to a method.
55+
56+
Note that Python supports many different class member types beyond simple methods.
57+
It may be useful or even necessary to use :lpy:fn:`reflect` to assess the specific
58+
type of a method-like class member."
59+
(some-fn inspect/ismethod inspect/isfunction inspect/ismethoddescriptor inspect/isbuiltin))
60+
61+
(extend-protocol Reflectable
62+
types/ModuleType
63+
(reflect* [this]
64+
(let [is-basilisp-module? (instance? basilisp.lang.runtime/BasilispModule this)
65+
members-by-group (group-by (fn [[_ member]]
66+
(cond
67+
(inspect/ismodule member) :modules
68+
(inspect/isclass member) :classes
69+
(inspect/isfunction member) :functions
70+
:else :attributes))
71+
(inspect/getmembers this))]
72+
{:name (symbol (python/getattr this "__name__"))
73+
:file (python/getattr this "__file__" nil)
74+
:package (symbol (python/getattr this "__package__" nil))
75+
:is-basilisp-module? is-basilisp-module?
76+
:basilisp-ns (when is-basilisp-module?
77+
(python/getattr this "__basilisp_namespace__"))
78+
:modules (members->map (:modules members-by-group))
79+
:classes (members->map (:classes members-by-group))
80+
:functions (members->map (:functions members-by-group))
81+
:attributes (members->map (:attributes members-by-group))}))
82+
python/type
83+
(reflect* [this]
84+
(let [members-by-group (group-by (fn [[_ member]]
85+
(cond
86+
(method-like? member) :methods
87+
(py-property? member) :properties
88+
:else :attributes))
89+
(inspect/getmembers this))]
90+
{:module (symbol (python/getattr this "__module__"))
91+
:qualified-name (qualname->sym (python/getattr this "__qualname__"))
92+
:name (symbol (python/getattr this "__name__"))
93+
:bases (set (bases this))
94+
:supers (supers this)
95+
:subclasses (subclasses this)
96+
:attributes (members->map (:attributes members-by-group))
97+
:methods (members->map (:methods members-by-group))
98+
:properties (members->map (:properties members-by-group))}))
99+
python/object
100+
(reflect* [this]
101+
(reflect* (python/type this)))
102+
nil
103+
(reflect* [this]
104+
nil))
105+
106+
;;;;;;;;;;;;;;;
107+
;; Callables ;;
108+
;;;;;;;;;;;;;;;
109+
110+
(def ^:private inspect-sig-kind-mapping
111+
{inspect.Parameter/POSITIONAL_ONLY :positional-only
112+
inspect.Parameter/POSITIONAL_OR_KEYWORD :positional-or-keyword
113+
inspect.Parameter/VAR_POSITIONAL :var-positional
114+
inspect.Parameter/KEYWORD_ONLY :keyword-only
115+
inspect.Parameter/VAR_KEYWORD :var-keyword})
116+
117+
(defn ^:private signature->map
118+
"Convert a Python ``inspect.Signature`` object into a map.
119+
120+
Signature maps include the following keys:
121+
122+
:keyword ``:parameters``: an vector of maps describing parameters to the callable
123+
in the strict order they were defined; parameter map keys are defined below
124+
:keyword ``:return-annotation``: the return annotation of the callable object or
125+
``::empty`` if no return annotation is defined
126+
127+
Parameter maps include the following keys:
128+
129+
:keyword ``:name``: the name of the parameter coerced to a symbol; the symbol
130+
will not be demunged
131+
:keyword ``:default``: the default value of this parameter if one is defined or
132+
``::empty`` otherwise
133+
:keyword ``:annotation``: the annotation of this parameter if one is defined or
134+
``::empty`` otherwise
135+
:keyword ``:kind``: the kind of Python parameter this is coerced to a keyword
136+
137+
In cases where a field may contain a reference to the ``inspect.Signature.empty``
138+
or ``inspect.Parameter.empty`` singletons, the corresponding Basilisp value is the
139+
namespaced keyword ``::empty``.
140+
"
141+
[^inspect/Signature sig]
142+
(let [return-anno (.-return-annotation sig)]
143+
{:parameters (mapv (fn [[param-name ^inspect/Parameter param]]
144+
(let [default (.-default param)
145+
anno (.-annotation param)
146+
kind (.-kind param)]
147+
{:name (symbol param-name)
148+
:default (if (operator/is default inspect.Parameter/empty)
149+
::empty
150+
default)
151+
:annotation (if (operator/is anno inspect.Parameter/empty)
152+
::empty
153+
anno)
154+
:kind (get inspect-sig-kind-mapping kind)}))
155+
(.items (.-parameters sig)))
156+
:return-annotation (if (operator/is return-anno inspect.Signature/empty)
157+
::empty
158+
return-anno)}))
159+
160+
(defn ^:private signature
161+
"Return the signature of a potentially callable object as a map if the signature
162+
can be determined, ``nil`` otherwise.
163+
164+
Signature maps contain the keys as described in :lpy:fn:`signature->map`."
165+
[f]
166+
(try
167+
(-> (inspect/signature f)
168+
(signature->map))
169+
(catch python/TypeError _ nil)
170+
(catch python/ValueError _ nil)))
171+
172+
(defn ^:private reflect-callable
173+
[f]
174+
{:qualified-name (qualname->sym (python/getattr f "__qualname__"))
175+
:name (symbol (python/getattr f "__name__"))
176+
:signature (signature f)
177+
:module (when-let [module (inspect/getmodule f)]
178+
(symbol (python/getattr module "__name__")))
179+
:doc (inspect/getdoc f)
180+
:file (try
181+
(inspect/getfile f)
182+
(catch python/TypeError _ nil))
183+
:flags (->> [(when (python/getattr f "_basilisp_fn" false) :basilisp-function)
184+
(when (inspect/isclass f) :class)
185+
(when (inspect/ismethod f) :method)
186+
(when (inspect/isfunction f) :function)
187+
(when (inspect/isgeneratorfunction f) :generator-function)
188+
(when (inspect/isgenerator f) :generator)
189+
(when (inspect/iscoroutine f) :coroutine)
190+
(when (inspect/isawaitable f) :awaitable)
191+
(when (inspect/isasyncgenfunction f) :async-generator-function)
192+
(when (inspect/isbuiltin f) :builtin)
193+
(when (inspect/isroutine f) :routine)
194+
(when (inspect/ismethoddescriptor f) :method-descriptor)
195+
#?@(:lpy311+ [(when (inspect/ismethodwrapper f) :method-wrapper)])]
196+
(filter identity)
197+
(set))})
198+
199+
(extend types/FunctionType Reflectable {:reflect* reflect-callable})
200+
(extend types/LambdaType Reflectable {:reflect* reflect-callable})
201+
(extend types/CoroutineType Reflectable {:reflect* reflect-callable})
202+
(extend types/MethodType Reflectable {:reflect* reflect-callable})
203+
(extend types/BuiltinFunctionType Reflectable {:reflect* reflect-callable})
204+
(extend types/BuiltinMethodType Reflectable {:reflect* reflect-callable})
205+
(extend types/WrapperDescriptorType Reflectable {:reflect* reflect-callable})
206+
(extend types/MethodWrapperType Reflectable {:reflect* reflect-callable})
207+
(extend types/MethodDescriptorType Reflectable {:reflect* reflect-callable})
208+
(extend types/ClassMethodDescriptorType Reflectable {:reflect* reflect-callable})
209+
210+
;;;;;;;;;;;;;;;;;;;
211+
;; Reflector API ;;
212+
;;;;;;;;;;;;;;;;;;;
213+
214+
(defprotocol Reflector
215+
(do-reflect [this typeref]
216+
"Reflect on the object named by ``typeref`` and return a map describing the object."))
217+
218+
(deftype PythonReflector []
219+
Reflector
220+
(do-reflect [_ typeref]
221+
(let [[mod-name obj] (.rsplit typeref "." 1)
222+
mod (importlib/import-module mod-name)]
223+
(reflect*
224+
(if obj
225+
(python/getattr mod obj)
226+
mod)))))
227+
228+
;;;;;;;;;;;;;;;;;;;;;;
229+
;; Public Interface ;;
230+
;;;;;;;;;;;;;;;;;;;;;;
231+
232+
(defn reflect
233+
"Reflect the object ``o`` and return details about its type as a map.
234+
235+
If ``o`` is a Python class (that is, it is an instance of ``type``), then [...]
236+
237+
If ``o`` is a callable (function, coroutine, method, builtin, etc.), then [...]
238+
239+
If ``o`` is a Python module, then [...]
240+
241+
If ``o`` is an object, then return the results of ``(reflect (type o))``.
242+
243+
If ``o`` is ``nil``, return ``nil``."
244+
[o]
245+
(reflect* o))
246+
247+
(defn type-reflect
248+
"Identical to :lpy:fn:`reflect`."
249+
[o]
250+
(reflect* o))

tests/basilisp/test_reflect.lpy

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
(ns tests.basilisp.test-reflect
2+
(:import collections.abc
3+
json)
4+
(:require
5+
basilisp.string
6+
[basilisp.reflect :refer [reflect typename]]
7+
[basilisp.test :refer [deftest is are testing]]))
8+
9+
(deftest typename-test
10+
(are [v] (thrown? basilisp.lang.exception/ExceptionInfo (typename v))
11+
nil
12+
0
13+
0.0
14+
:kw
15+
'sym
16+
"str"
17+
[]
18+
{}
19+
#{}
20+
'())
21+
22+
(are [expected v] (= expected (typename v))
23+
"json" json
24+
"collections.abc.Mapping" collections.abc/Mapping
25+
"basilisp.lang.vector.PersistentVector" (type [])))
26+
27+
(defn ^:private is-member-map?
28+
[[s _]]
29+
(symbol? s))
30+
31+
(deftest reflect-test
32+
(is (nil? (reflect nil)))
33+
34+
(testing "module type"
35+
(let [{:keys [name package] :as m} (reflect json)]
36+
(is (= 'json name))
37+
(is (contains? m :file))
38+
(is (= 'json package))
39+
(is (false? (:is-basilisp-module? m)))
40+
(is (nil? (:basilisp-ns m)))
41+
(is (every? is-member-map? (:modules m)))
42+
(is (every? is-member-map? (:classes m)))
43+
(is (contains? (:classes m) 'JSONDecoder))
44+
(is (every? is-member-map? (:functions m)))
45+
(is (contains? (:functions m) 'dumps))
46+
(is (every? is-member-map? (:attributes m)))
47+
(is (contains? (:attributes m) '__name__)))
48+
49+
(let [{:keys [name package] :as m} (reflect (.-module (the-ns 'basilisp.string)))]
50+
(is (= 'basilisp.string name))
51+
(is (contains? m :file))
52+
(is (= 'basilisp package))
53+
(is (true? (:is-basilisp-module? m)))
54+
(is (not (nil? (:basilisp-ns m))))
55+
(is (every? is-member-map? (:modules m)))
56+
(is (every? is-member-map? (:classes m)))
57+
(is (every? is-member-map? (:functions m)))
58+
(is (every? is-member-map? (:attributes m)))))
59+
60+
(testing "type"
61+
(let [{:keys [name qualified-name module] :as m} (reflect python/str)]
62+
(is (= name 'str))
63+
(is (= qualified-name 'str))
64+
(is (= module 'builtins))
65+
(is (every? class? (:supers m)))
66+
(is (set? (:supers m)))
67+
(is (every? class? (:bases m)))
68+
(is (set? (:bases m)))
69+
(is (every? class? (:subclasses m)))
70+
(is (set? (:subclasses m)))
71+
(is (every? is-member-map? (:attributes m)))
72+
(is (contains? (:attributes m) '__doc__))
73+
(is (every? is-member-map? (:methods m)))
74+
(is (contains? (:methods m) 'startswith))
75+
(is (= {} (:properties m)))))
76+
77+
(testing "callable"
78+
(let [{:keys [name qualified-name module signature] :as m} (reflect keyword)]
79+
(is (= name 'keyword))
80+
(is (= qualified-name 'keyword))
81+
(is (= module 'basilisp.core))
82+
(is (string? (:file m)))
83+
(is (= #{:function :routine :basilisp-function} (:flags m)))
84+
(is (= :basilisp.reflect/empty (:return-annotation signature))))))

0 commit comments

Comments
 (0)