Repository: plotly/dash

Issue:
ImportError: cannot import name 'get_current_traceback' from 'werkzeug.debug.tbtools'
I am working on a Dash app and it was working great till yesterday. When I tried today, I am seeing the following:

Traceback (most recent call last):
  File "/app/app-v2.py", line 12, in <module>
    import dash
  File "/usr/local/lib/python3.9/site-packages/dash/__init__.py", line 5, in <module>
    from .dash import Dash, no_update  # noqa: F401,E402
  File "/usr/local/lib/python3.9/site-packages/dash/dash.py", line 18, in <module>
    from werkzeug.debug.tbtools import get_current_traceback
ImportError: cannot import name 'get_current_traceback' from 'werkzeug.debug.tbtools' (/usr/local/lib/python3.9/site-packages/werkzeug/debug/tbtools.py)

I am running the app on macOS Catalina. Happy to provide more details. 


Discussion:
Update. I have downgraded the werkzeug version to 2.0.0 and it works. For some reason, 2.1.0 doesn't. FYI. Thanks for your efforts. 
Just commenting that I hit the same issue with [Werkzeug](https://pypi.org/project/Werkzeug/2.1.0/#history)--pinning 2.0.3 seemed to resolve--Linux Mint 20.3
From Werkzeug 2.1.0 release notes (https://werkzeug.palletsprojects.com/en/2.1.x/changes/#version-2-1-0):

> Refactor the debugger traceback formatter to use Python’s built-in traceback module as much as possible. [#1753](https://github.com/pallets/werkzeug/issues/1753)
It seems that `get_current_traceback` changed to [DebugTraceback](https://github.com/pallets/werkzeug/blob/eea02fb8394d66341b2ce0585b228732ef81eeea/src/werkzeug/debug/tbtools.py#L213)

I simply replaced `get_current_traceback` by `DebugTraceback` in the `dash.py` file and it worked
I can't downgrade. I have Python 3.8.8

If I run
`pip install -i Werkzeug== 2.1.0`

I get

Looking in indexes: Werkzeug==
WARNING: Url 'Werkzeug==/2-1-0/' is ignored. It is either a non-existing path or lacks a specific scheme.
ERROR: Could not find a version that satisfies the requirement 2.1.0 (from versions: none)
ERROR: No matching distribution found for 2.1.0

@[michelegentili93](https://github.com/michelegentili93)
> pip install -i Werkzeug== 2.1.0

you have a space between the `==` and the version number, this is probably what causes the error
Try `pip install -i Werkzeug==2.1.0`

> It seems that `get_current_traceback` changed to [DebugTraceback](https://github.com/pallets/werkzeug/blob/eea02fb8394d66341b2ce0585b228732ef81eeea/src/werkzeug/debug/tbtools.py#L213)
> 
> I simply replaced `get_current_traceback` by `DebugTraceback` in the `dash.py` file and it worked

@Bachibouzouk I did this initially in #1993, but that would mean giving up Python 3.6 compatibility. I'm not sure what the Dash view is on this, given it's EOL (I couldn't find any open issues about it on GitHub), but I figured we should just bound the dependencies until there's an explicit agreement to drop 3.6.
`pip install -i Werkzeug==2.1.0` didn't work for me, but `pip install Werkzeug==2.0.0` did. 
Pinning a dependency’s max version is a good idea in the following situations:

- a temporary fix for CI since the dependency has a bug (this is what people depending on dash are doing right now)
- a medium term fix for an end user application (i.e. a project that isn’t imported by any other)

It’s not a good medium or even long term solution for a library like dash. Library projects should have conditional code that copes with both new and old dependency versions if possible, and if not, raise *minimum* dependency versions (in this case, Python’s)

see here for reasons: https://iscinumpy.dev/post/bound-version-constraints/
> > It seems that `get_current_traceback` changed to [DebugTraceback](https://github.com/pallets/werkzeug/blob/eea02fb8394d66341b2ce0585b228732ef81eeea/src/werkzeug/debug/tbtools.py#L213)
> > I simply replaced `get_current_traceback` by `DebugTraceback` in the `dash.py` file and it worked
> 
> @Bachibouzouk I did this initially in #1993, but that would mean giving up Python 3.6 compatibility. I'm not sure what the Dash view is on this, given it's EOL (I couldn't find any open issues about it on GitHub), but I figured we should just bound the dependencies until there's an explicit agreement to drop 3.6.

@deepyaman Thanks for attempting a PR fix on this. I haven't looked at the details yet (except pinning version in our CI), but if it is only the name that has changed of what is imported, would something like the common import catching pattern
```python
try:
   from werkzeug.debug.tbtools import DebugTraceback
except ImportError:  # temporary fallback for werkzeug<2.1
   from werkzeug.debug.tbtools import get_current_traceback as DebugTraceback
```
(together with replacing `get_current_traceback` with `DebugTraceback` where it is used) work also here?
> (together with replacing `get_current_traceback` with `DebugTraceback` where it is used) work also here?

@anders-kiaer - this work to get rid of the error, I am not sure it keeps the desired functionality (The `DebugTraceback` class seem to take an exception as argument upon initialisation looking at the PR which did the change https://github.com/pallets/werkzeug/pull/2333)
> > > It seems that `get_current_traceback` changed to [DebugTraceback](https://github.com/pallets/werkzeug/blob/eea02fb8394d66341b2ce0585b228732ef81eeea/src/werkzeug/debug/tbtools.py#L213)
> > > I simply replaced `get_current_traceback` by `DebugTraceback` in the `dash.py` file and it worked
> > 
> > 
> > @Bachibouzouk I did this initially in #1993, but that would mean giving up Python 3.6 compatibility. I'm not sure what the Dash view is on this, given it's EOL (I couldn't find any open issues about it on GitHub), but I figured we should just bound the dependencies until there's an explicit agreement to drop 3.6.
> 
> @deepyaman Thanks for attempting a PR fix on this. I haven't looked at the details yet (except pinning version in our CI), but if it is only the name that has changed of what is imported, would something like the common import catching pattern
> 
> ```python
> try:
>    from werkzeug.debug.tbtools import DebugTraceback
> except ImportError:  # temporary fallback for werkzeug<2.1
>    from werkzeug.debug.tbtools import get_current_traceback as DebugTraceback
> ```
> 
> (together with replacing `get_current_traceback` with `DebugTraceback` where it is used) work also here?

@anders-kiaer I think it would work with a bit more effort. `DebugTraceback` takes the exception as an argument. However, what we can do is to just implement `DebugTraceback` for `werkzeug<2.1` as:

```python
def DebugTraceback(e: Exception):
    return get_current_traceback()
```

or something like that (very rough here).
> Pinning a dependency’s max version is a good idea in the following situations:
> 
> * a temporary fix for CI since the dependency has a bug (this is what people depending on dash are doing right now)
> * a medium term fix for an end user application (i.e. a project that isn’t imported by any other)
> 
> It’s not a good medium or even long term solution for a library like dash. Library projects should have conditional code that copes with both new and old dependency versions if possible, and if not, raise _minimum_ dependency versions (in this case, Python’s)
> 
> see here for reasons: https://iscinumpy.dev/post/bound-version-constraints/

Interesting article, thanks for sharing. Just for context, I was alerted this this issues due to a tool we've been working on called [edgetest](https://github.com/capitalone/edgetest). It helps automate the upper pins and is in the same realm of  GitHub's dependabot. Full disclosure I'm one of the devs who works on this package. Just sharing incase this could be useful to anyone.

At a minimum it will test the latest versions and bump them up only if your CICD testing passes. There is also an [action](https://github.com/marketplace/actions/run-edgetest) to help automate some of this.
> > > > It seems that `get_current_traceback` changed to [DebugTraceback](https://github.com/pallets/werkzeug/blob/eea02fb8394d66341b2ce0585b228732ef81eeea/src/werkzeug/debug/tbtools.py#L213)
> > > > I simply replaced `get_current_traceback` by `DebugTraceback` in the `dash.py` file and it worked
> > > 
> > > 
> > > @Bachibouzouk I did this initially in #1993, but that would mean giving up Python 3.6 compatibility. I'm not sure what the Dash view is on this, given it's EOL (I couldn't find any open issues about it on GitHub), but I figured we should just bound the dependencies until there's an explicit agreement to drop 3.6.
> > 
> > 
> > @deepyaman Thanks for attempting a PR fix on this. I haven't looked at the details yet (except pinning version in our CI), but if it is only the name that has changed of what is imported, would something like the common import catching pattern
> > ```python
> > try:
> >    from werkzeug.debug.tbtools import DebugTraceback
> > except ImportError:  # temporary fallback for werkzeug<2.1
> >    from werkzeug.debug.tbtools import get_current_traceback as DebugTraceback
> > ```
> > 
> > 
> >     
> >       
> >     
> > 
> >       
> >     
> > 
> >     
> >   
> > (together with replacing `get_current_traceback` with `DebugTraceback` where it is used) work also here?
> 
> @anders-kiaer I think it would work with a bit more effort. `DebugTraceback` takes the exception as an argument. However, what we can do is to just implement `DebugTraceback` for `werkzeug<2.1` as:
> 
> ```python
> def DebugTraceback(e: Exception):
>     return get_current_traceback()
> ```
> 
> or something like that (very rough here).

Actually, there's some other work that would need to be done. Working on an implementation, but here's another change from:

https://github.com/pallets/werkzeug/blob/a04f5cc32be020babeaf9474f7ac735422ddc824/src/werkzeug/debug/__init__.py#L329-L331

to:

https://github.com/pallets/werkzeug/blob/560dd5f320bff318175f209595d42f5a80045417/src/werkzeug/debug/__init__.py#L325-L329

But I can account for this.

Resolving patch:
diff --git a/dash/_utils.py b/dash/_utils.py
--- a/dash/_utils.py
+++ b/dash/_utils.py
@@ -8,6 +8,8 @@
 import logging
 import io
 import json
+import secrets
+import string
 from functools import wraps
 
 logger = logging.getLogger()
@@ -206,3 +208,9 @@ def _wrapper(*args, **kwargs):
         return _wrapper
 
     return wrapper
+
+
+def gen_salt(chars):
+    return "".join(
+        secrets.choice(string.ascii_letters + string.digits) for _ in range(chars)
+    )
diff --git a/dash/dash.py b/dash/dash.py
--- a/dash/dash.py
+++ b/dash/dash.py
@@ -11,11 +11,12 @@
 import mimetypes
 import hashlib
 import base64
+import traceback
 from urllib.parse import urlparse
 
 import flask
 from flask_compress import Compress
-from werkzeug.debug.tbtools import get_current_traceback
+
 from pkg_resources import get_distribution, parse_version
 from dash import dcc
 from dash import html
@@ -48,6 +49,7 @@
     patch_collections_abc,
     split_callback_id,
     to_json,
+    gen_salt,
 )
 from . import _callback
 from . import _get_paths
@@ -102,6 +104,42 @@
 _re_renderer_scripts_id = 'id="_dash-renderer', "new DashRenderer"
 
 
+def _get_traceback(secret, error: Exception):
+
+    try:
+        # pylint: disable=import-outside-toplevel
+        from werkzeug.debug import tbtools
+    except ImportError:
+        tbtools = None
+
+    def _get_skip(text, divider=2):
+        skip = 0
+        for i, line in enumerate(text):
+            if "%% callback invoked %%" in line:
+                skip = int((i + 1) / divider)
+                break
+        return skip
+
+    # werkzeug<2.1.0
+    if hasattr(tbtools, "get_current_traceback"):
+        tb = tbtools.get_current_traceback()
+        skip = _get_skip(tb.plaintext.splitlines())
+        return tbtools.get_current_traceback(skip=skip).render_full()
+
+    if hasattr(tbtools, "DebugTraceback"):
+        tb = tbtools.DebugTraceback(error)  # pylint: disable=no-member
+        skip = _get_skip(tb.render_traceback_text().splitlines())
+
+        # pylint: disable=no-member
+        return tbtools.DebugTraceback(error, skip=skip).render_debugger_html(
+            True, secret, True
+        )
+
+    tb = traceback.format_exception(type(error), error, error.__traceback__)
+    skip = _get_skip(tb, 1)
+    return tb[0] + "".join(tb[skip:])
+
+
 class _NoUpdate:
     # pylint: disable=too-few-public-methods
     pass
@@ -1756,19 +1794,16 @@ def enable_dev_tools(
 
         if debug and dev_tools.prune_errors:
 
+            secret = gen_salt(20)
+
             @self.server.errorhandler(Exception)
-            def _wrap_errors(_):
+            def _wrap_errors(error):
                 # find the callback invocation, if the error is from a callback
                 # and skip the traceback up to that point
                 # if the error didn't come from inside a callback, we won't
                 # skip anything.
-                tb = get_current_traceback()
-                skip = 0
-                for i, line in enumerate(tb.plaintext.splitlines()):
-                    if "%% callback invoked %%" in line:
-                        skip = int((i + 1) / 2)
-                        break
-                return get_current_traceback(skip=skip).render_full(), 500
+                tb = _get_traceback(secret, error)
+                return tb, 500
 
         if debug and dev_tools.ui:
 


Test patch: