Repository: iterative/dvc

Issue:
DVC v.1.6.6 cannot be imported after Bokeh v.22.1 as both uses logging levelName = "TRACE"
## Bug Report
It has been a long time since I updated my DVC version - previously, I have used version 0.84.0 - but I have now updated to version 1.6.6. However, I get an assert error, when I first import Bokeh, before I import DVC in my python script - as seen in the image below (it fails both with `import dvc` and `import dvc.api`).
![dvc error](https://user-images.githubusercontent.com/5435299/92484948-9d2f8580-f1ea-11ea-9e01-eac0d9c7a0a9.png)

The assert shows that both DVC and Bokeh creates a loggingLevel and loggerClass, named "TRACE" and "trace", respectively.
I think DVC should handle - i.e. not fail at - this edge case, where TRACE already have been defined by another Python package or user code before DVC is imported.

PS: Thanks for the big improvements all DVC developer have done since version 0.84.0 - I'm so excited to use all of the new features 😄

### Please provide information about your setup

**Output of `dvc version`:**

```console
$ dvc version
DVC version: 1.6.6 (conda)
---------------------------------
Platform: Python 3.8.2 on Linux-5.4.0-45-generic-x86_64-with-glibc2.10
Supports: azure, gdrive, gs, hdfs, http, https, s3, ssh, oss
```



Discussion:
Might be solved by https://github.com/iterative/dvc/pull/4487 

EDIT: though, not really, because https://github.com/iterative/dvc/pull/4487/files#diff-c0226b8816d2461ece01fdbbd7c64064R185 we still setup the new log level. Need to check.
@PeterFogh Btw, what dvc API functionality were you planning to use here? Would a simple workaround of calling dvc through CLI do the trick for you for now?
@efiop,
Well, we have used the `dvc.repo.Repo('.').graph` to save the DVC pipeline as a experiment artifact (pydot graph to a PNG image). However, we know that the DVC API is changing, and often we just fix our code to the new versions of the API.

@efiop, do the new API already have the same functionality?
@PeterFogh Sure, `dvc.repo.Repo('.').graph` is still there. You could use `dvc dag --dot` through CLI though, to save the dag in dot format.
@efiop - super. However, we may still want to do in in Python - such that we only have one DVC stage (Python script) creating all of our artifacts.
@PeterFogh Totally. Just making sure it is not a blocker for you. Ideally, we should take a closer look at our logger in general in https://github.com/iterative/dvc/pull/4487 , but maybe there is a quick fix in a shape of changing TRACE to DVC_TRACE or something, just to not run into conflicts with bokeh. Though, the conflicts are pretty harmless, we could also consider removing that assert, for now, to quickly unblock this. 
@efiop Dask also creates the same logging attribute "TRACE" as DVC does and we have again the issue about the import order.

![dvc_attribute_error](https://user-images.githubusercontent.com/23300143/94788935-956f9500-03d4-11eb-9c19-e112974e9981.PNG)


DVC version: 1.7.2 (conda)
---------------------------------
Platform: Python 3.8.2 on Linux-5.4.0-48-generic-x86_64-with-glibc2.10
Supports: azure, gdrive, gs, hdfs, http, https, s3, ssh, oss
Cache types: hardlink, symlink
Cache directory: ext4 on /dev/sda1
Workspace directory: ext4 on /dev/sda1
Repo: dvc, git

@efiop This is currently a problem in applications that want to use `dvc.api` to retrieve data files from a `dvc` data repo. `dask`, and to a lesser extent, `bokeh` are pretty common parts of the python data science stack, so I think this may become more pressing as `dvc.api` sees more uptake.

Would you be open to fix at https://github.com/iterative/dvc/blob/cf1070a531456a267c3b57a35d17795397437699/dvc/logger.py#L21-L46
that adds the level if not present, rather than failing if the level is already exists?

As far as I can tell though a quick glace, DVC doesn't have a hard dependency on the numeric logging level and accesses the level the `trace` or `TRACE` symbolic names so this "should" be safe.

Resolving patch:
diff --git a/dvc/logger.py b/dvc/logger.py
--- a/dvc/logger.py
+++ b/dvc/logger.py
@@ -23,14 +23,19 @@ def addLoggingLevel(levelName, levelNum, methodName=None):
     Adds a new logging level to the `logging` module and the
     currently configured logging class.
 
+    Uses the existing numeric levelNum if already defined.
+
     Based on https://stackoverflow.com/questions/2183233
     """
     if methodName is None:
         methodName = levelName.lower()
 
-    assert not hasattr(logging, levelName)
-    assert not hasattr(logging, methodName)
-    assert not hasattr(logging.getLoggerClass(), methodName)
+    # If the level name is already defined as a top-level `logging`
+    # constant, then adopt the existing numeric level.
+    if hasattr(logging, levelName):
+        existingLevelNum = getattr(logging, levelName)
+        assert isinstance(existingLevelNum, int)
+        levelNum = existingLevelNum
 
     def logForLevel(self, message, *args, **kwargs):
         if self.isEnabledFor(levelNum):
@@ -40,10 +45,19 @@ def logForLevel(self, message, *args, **kwargs):
     def logToRoot(message, *args, **kwargs):
         logging.log(levelNum, message, *args, **kwargs)
 
-    logging.addLevelName(levelNum, levelName)
-    setattr(logging, levelName, levelNum)
-    setattr(logging.getLoggerClass(), methodName, logForLevel)
-    setattr(logging, methodName, logToRoot)
+    # getLevelName resolves the numeric log level if already defined,
+    # otherwise returns a string
+    if not isinstance(logging.getLevelName(levelName), int):
+        logging.addLevelName(levelNum, levelName)
+
+    if not hasattr(logging, levelName):
+        setattr(logging, levelName, levelNum)
+
+    if not hasattr(logging.getLoggerClass(), methodName):
+        setattr(logging.getLoggerClass(), methodName, logForLevel)
+
+    if not hasattr(logging, methodName):
+        setattr(logging, methodName, logToRoot)
 
 
 class LoggingException(Exception):


Test patch:
diff --git a/tests/unit/test_logger.py b/tests/unit/test_logger.py
--- a/tests/unit/test_logger.py
+++ b/tests/unit/test_logger.py
@@ -248,3 +248,31 @@ def test_info_with_debug_loglevel_shows_no_datetime(caplog, dt):
         logger.info("message")
 
         assert "message" == formatter.format(caplog.records[0])
+
+
+def test_add_existing_level(caplog, dt):
+    # Common pattern to configure logging level in external libraries
+    # eg:
+    # https://github.com/bokeh/bokeh/blob/04bb30fef2e72e64baaa8b2f330806d5bfdd3b11/
+    # bokeh/util/logconfig.py#L79-L85
+    TRACE2 = 4
+    logging.addLevelName(TRACE2, "TRACE2")
+    logging.TRACE2 = TRACE2
+
+    dvc.logger.addLoggingLevel("TRACE2", 2)
+
+    # DVC sets all expected entrypoints, but doesn't override the level
+    assert logging.TRACE2 == 4
+    assert hasattr(logging, "trace2")
+    assert hasattr(logger, "trace2")
+    assert logging.getLevelName("TRACE2") == 4
+
+    # The TRACE2 logging level uses the original, higher logging level
+    with caplog.at_level(logging.TRACE2, logger="dvc"):
+        logger.trace2("TRACE2")
+    assert len(caplog.records) == 1
+
+    (record,) = caplog.records
+    assert record.levelno == 4
+    assert record.levelname == "TRACE2"
+    assert record.message == "TRACE2"