Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Added filename metadata to compiler exceptions (#844)
* Added a compile-time warning for attempting to call a function with an unsupported number of arguments (#671)
* Added support for explicit cause exception chaining to the `throw` special form (#862)
* Added `basilisp.stacktrace` namespace (#721)

### Changed
* Cause exceptions arising from compilation issues during macroexpansion will no longer be nested for each level of macroexpansion (#852)
Expand Down
80 changes: 75 additions & 5 deletions src/basilisp/stacktrace.lpy
Original file line number Diff line number Diff line change
@@ -1,13 +1,83 @@
(ns basilisp.stacktrace
"Prints stacktraces."
"Utility functions for printing stack traces."
(:require [basilisp.string :as str])
(:import [traceback :as tb]))

(defn root-cause
"Return the root cause exception of the possible chain of exceptions ``exc``."
[^python/BaseException exc]
(loop [e exc]
(if-let [cause (.-__cause__ e)]
(recur cause)
e)))

(defn context
"Return any context exception to the exception ``exc``.

Context exceptions may be the same as cause exceptions. Typically, when throwing an
exception with an explicit cause the context exception is suppressed (via
``BaseException.__suppress_context__``). If called with one argument, this function
will use the value of ``__suppress_context__`` for ``suppress-context?``. If called
with two arguments, the caller can specify if context should be returned or suppressed."
([^python/BaseException exc]
(context exc (.-__suppress_context__ exc)))
([^python/BaseException exc suppress-context?]
(when-not suppress-context?
(.-__context__ exc))))

(defn print-stack-trace
"Prints up to ``n`` stack frames from the traceback of the exception ``exc``, not
including chained exceptions (causes and context exceptions).

To print exception tracebacks including causes, use :lpy:fn:`print-cause-trace`.

If ``n`` is not given, return all frames."
([exc]
(print-stack-trace exc nil))
([exc n]
(->> (tb/format_exception (python/type exc)
exc
(.-__traceback__ exc)
**
:limit n
:chain false)
(str/join " ")
print)))

(defn print-cause-trace
"Prints the stacktrace of chained ``exc`` (cause), using ``n`` stack
frames (defaults to all)."
"Prints up to ``n`` stack frames from the traceback of the exception ``exc``,
including chained exceptions (causes and context exceptions).

To print only the trace for the given exception, use :lpy:fn:`print-stack-trace`.

If ``n`` is not given, return all frames."
([exc]
(print-cause-trace exc nil))
([exc n]
(print (str/join " " (tb/format_exception (python/type exc) exc (.-__traceback__ exc)
** :limit n :chain true)))))
(->> (tb/format_exception (python/type exc)
exc
(.-__traceback__ exc)
**
:limit n
:chain true)
(str/join " ")
print)))

(defn print-throwable
"Print the type and message of exception ``exc``.

Prints the :lpy:fn:`ex-data` map if present."
[exc]
(let [exc-type (type exc)
data-str (if-let [d (ex-data exc)]
(str " " d)
"")]
(println
(str (.-__module__ exc-type) "." (.-__qualname__ exc-type) ": " (ex-message exc) data-str))))

(defn e
"REPL utility for printing the root cause (via :lpy:fn:`root-cause`) of :lpy:var:`*e`
if an exception is bound."
[]
(when *e
(print-stack-trace (root-cause *e))))
61 changes: 60 additions & 1 deletion tests/basilisp/test_stacktrace.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,53 @@
(defn- exception-test []
(/ 5 0))

(deftest stacktrace-basic
(defn- chained-exception []
(try
(/ 5.0 0)
(catch python/ZeroDivisionError e
(throw (python/ValueError "Division by zero") e))))

(defn- context-exception []
(try
(/ 5.0 0)
(catch python/ZeroDivisionError e
(throw (python/ValueError "Division by zero")))))

(deftest root-cause-test
(testing "no root cause"
(try
(exception-test)
(catch python/ZeroDivisionError e
(is (identical? e (s/root-cause e))))))

(testing "with root cause"
(try
(chained-exception)
(catch python/ZeroDivisionError _
(is false))
(catch python/ValueError e
(is (instance? python/ZeroDivisionError (s/root-cause e)))))))

(deftest context-test
(testing "context only exception"
(try
(context-exception)
(catch python/ZeroDivisionError _
(is false))
(catch python/ValueError e
(is (identical? e (s/root-cause e)))
(is (instance? python/ZeroDivisionError (s/context e))))))

(testing "explicit cause exception"
(try
(chained-exception)
(catch python/ValueError e
(is (instance? python/ZeroDivisionError (s/root-cause e)))
(is (instance? python/ZeroDivisionError (s/context e false)))
(is (identical? (s/root-cause e) (s/context e false)))
(is (nil? (s/context e)))))))

(deftest print-cause-trace-test
(try
(exception-test)
(catch python/Exception e
Expand All @@ -25,3 +71,16 @@
(is (= "Traceback (most recent call last):" (first trace)))
(is (= [" raise ZeroDivisionError('Fraction(%s, 0)' % numerator)"
" ZeroDivisionError: Fraction(5, 0)" ] (take-last 2 trace)))))))

(deftest print-throwable
(try
(exception-test)
(catch python/ZeroDivisionError e
(is (= "builtins.ZeroDivisionError: Fraction(5, 0)"
(str/trim (with-out-str (s/print-throwable e)))))))

(try
(throw (ex-info "Super bad exception" {:severity :bad!}))
(catch python/Exception e
(is (= "basilisp.lang.exception.ExceptionInfo: Super bad exception {:severity :bad!}"
(str/trim (with-out-str (s/print-throwable e))))))))