Repository: stan-dev/cmdstanpy
Pull request #796 (2025-06-18 01:03:25)

Enable/disable logging helpers
#### Submission Checklist

- [x] Run unit tests
- [x] Declare copyright holder and open-source license: see below

#### Summary

This is an example implementation to meet #795. It also includes a bug fix pointed out by @skelliest. The primary contribution is the inclusion of the `enable_logging/disable_logging` "functions" at the module level.

Here's an example demonstration of usage:

```python
import cmdstanpy

cmdstanpy.disable_logging()

model = cmdstanpy.CmdStanModel(...)
model.sample(chains=1, show_progress=False)

cmdstanpy.enable_logging()
```

I also tried to be clever and make this same interface work as a context manager, so you can do:

```python
import cmdstanpy

with cmdstanpy.disable_logging():
    model = cmdstanpy.CmdStanModel(...)
    model.sample(chains=1, show_progress=False)
```

I like the idea of this functionality, but the implementation may be stretching the bounds of what is reasonable. For one, the `disable_logging`/`enable_logging` are actually classes so the function call in `cmdstanpy.disable_logging()` actually returns an instance of the `cmdstanpy.utils.logging.disable_logging` class, but this is the only way I could conceive of getting the same interface to function as both a "function" and as a context manager.

The most noticeable result of this is that if a user were to call `cmdstanpy.disable_logging()` in a standalone cell in a Jupyter notebook; they'd see the default `repr` in the cell output. You can get around this by putting a semicolon at the end of the line, which is what I did in the `outputs.rst` docs, but it's more of a band-aid. This bothers me, but I don't see a way around it and also maintain the dual-interface.

Some feedback/thoughts I'd like:

1. Is the juice not worth the squeeze for this dual "function"/context manager?
2. If the dual interface is fine, should the `__repr__`s for these objects be updated so if called in an empty Juypter cell, it's a little cleaner?
3. If not, what would the preferred solution be? Some variations off the top of my head:

* Remove the context manager and implement as a simple function. Probably the most straightforward approach, but will require users to manually re-enable logging after disabling.
* Create separate interfaces for the function and the context manager, something like:
```python
import cmdstanpy

with cmdstanpy.context(logging=False):
  model.sample()
```  
in addition to `cmdstanpy.disable_logging()`

* Remove the _function_ and only have a context manager option. This would encourage only local disabling of logging and the like, while of course leaving open the option to disable logging globally the old fashioned way. 

Probably overthinking what is a relatively minor change, happy to hear some thoughts.

#### Copyright and Licensing

Please list the copyright holder for the work you are submitting (this will be you or your assignee, such as a university or company): myself



By submitting this pull request, the copyright holder is agreeing to license the submitted work under the following licenses:
- Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)




Patch:
diff --git a/cmdstanpy/__init__.py b/cmdstanpy/__init__.py
index 4e3c134..a83f9d9 100644
--- a/cmdstanpy/__init__.py
+++ b/cmdstanpy/__init__.py
@@ -42,6 +42,8 @@ from .utils import (
     set_make_env,
     show_versions,
     write_stan_json,
+    enable_logging,
+    disable_logging,
 )
 
 __all__ = [
@@ -63,4 +65,6 @@ __all__ = [
     'show_versions',
     'rebuild_cmdstan',
     'cmdstan_version',
+    "enable_logging",
+    "disable_logging",
 ]
diff --git a/cmdstanpy/utils/__init__.py b/cmdstanpy/utils/__init__.py
index 9b2da41..8d61a28 100644
--- a/cmdstanpy/utils/__init__.py
+++ b/cmdstanpy/utils/__init__.py
@@ -28,7 +28,7 @@ from .filesystem import (
     windows_short_path,
 )
 from .json import write_stan_json
-from .logging import get_logger
+from .logging import get_logger, enable_logging, disable_logging
 from .stancsv import (
     check_sampler_csv,
     parse_rdump_value,
@@ -144,4 +144,6 @@ __all__ = [
     'windows_short_path',
     'wrap_url_progress_hook',
     'write_stan_json',
+    'enable_logging',
+    'disable_logging',
 ]
diff --git a/cmdstanpy/utils/logging.py b/cmdstanpy/utils/logging.py
index 6f33ec8..f611590 100644
--- a/cmdstanpy/utils/logging.py
+++ b/cmdstanpy/utils/logging.py
@@ -1,15 +1,19 @@
 """
 CmdStanPy logging
 """
+
 import functools
 import logging
+import types
+from contextlib import AbstractContextManager
+from typing import Optional, Type
 
 
 @functools.lru_cache(maxsize=None)
 def get_logger() -> logging.Logger:
     """cmdstanpy logger"""
-    logger = logging.getLogger('cmdstanpy')
-    if len(logger.handlers) == 0:
+    logger = logging.getLogger("cmdstanpy")
+    if not logger.hasHandlers():
         # send all messages to handlers
         logger.setLevel(logging.DEBUG)
         # add a default handler to the logger to INFO and higher
@@ -17,9 +21,42 @@ def get_logger() -> logging.Logger:
         handler.setLevel(logging.INFO)
         handler.setFormatter(
             logging.Formatter(
-                '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+                "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
                 "%H:%M:%S",
             )
         )
         logger.addHandler(handler)
     return logger
+
+
+class ToggleLogging(AbstractContextManager):
+    def __init__(self, disable: bool) -> None:
+        self.disable = disable
+        self.logger = get_logger()
+        self.prev_state = self.logger.disabled
+        self.logger.disabled = self.disable
+
+    def __repr__(self) -> str:
+        return ""
+
+    def __enter__(self) -> "ToggleLogging":
+        self.logger.disabled = self.disable
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc_value: Optional[BaseException],
+        traceback: Optional[types.TracebackType],
+    ) -> None:
+        self.logger.disabled = self.prev_state
+
+
+def enable_logging() -> ToggleLogging:
+    """Enable cmdstanpy logging. Can be used as a context manager"""
+    return ToggleLogging(disable=False)
+
+
+def disable_logging() -> ToggleLogging:
+    """Disable cmdstanpy logging. Can be used as a context manager"""
+    return ToggleLogging(disable=True)
diff --git a/docsrc/api.rst b/docsrc/api.rst
index 88d4dfc..8e2af1d 100644
--- a/docsrc/api.rst
+++ b/docsrc/api.rst
@@ -139,3 +139,13 @@ write_stan_json
 ===============
 
 .. autofunction:: cmdstanpy.write_stan_json
+
+enable_logging
+===============
+
+.. autofunction:: cmdstanpy.enable_logging
+
+disable_logging
+===============
+
+.. autofunction:: cmdstanpy.disable_logging
diff --git a/docsrc/users-guide/outputs.rst b/docsrc/users-guide/outputs.rst
index 27d42c0..56f650a 100644
--- a/docsrc/users-guide/outputs.rst
+++ b/docsrc/users-guide/outputs.rst
@@ -60,24 +60,47 @@ You may notice CmdStanPy can produce a lot of output when it is running:
 
     fit = model.sample(data=data_file, show_progress=False)
 
-This output is managed through the built-in :mod:`logging` module. For example, it can be disabled entirely:
+This output is managed through the built-in :mod:`logging` module. For
+convenience, CmdStanPy provides the module-level :meth:`cmdstanpy.enable_logging()`
+and :meth:`cmdstanpy.disable_logging()` functions to simplify logging management.
+
+For example, it can be disabled entirely:
 
 .. ipython:: python
 
-    import logging
-    cmdstanpy_logger = logging.getLogger("cmdstanpy")
-    cmdstanpy_logger.disabled = True
+    import cmdstanpy
+    cmdstanpy.disable_logging()
     # look, no output!
     fit = model.sample(data=data_file, show_progress=False)
 
-Or one can remove the logging handler that CmdStanPy installs by default and install their own for more
-fine-grained control. For example, the following code sends all logs (including the ``DEBUG`` logs, which are hidden by default),
-to a file.
+We can re-enable this by calling ``enable_logging()``:
+
+.. ipython:: python
+
+    cmdstanpy.enable_logging()
+    fit = model.sample(data=data_file, show_progress=False)
+
+
+These functions also work as context managers for more local control:
+
+.. ipython:: python
+
+    with cmdstanpy.disable_logging():
+        fit = model.sample(data=data_file, show_progress=False)
+
+
+For more fine-grained control, one can interact with the underlying ``logging``
+library directly. For example, the following code installs a custom handler
+that sends all logs (including the ``DEBUG`` logs, which are hidden by
+default), to a file.
 
 DEBUG logging is useful primarily to developers or when trying to hunt down an issue.
 
 .. ipython:: python
 
+    import logging
+    cmdstanpy_logger = logging.getLogger("cmdstanpy")
+
     cmdstanpy_logger.disabled = False
     # remove all existing handlers
     cmdstanpy_logger.handlers = []
@@ -93,6 +116,7 @@ DEBUG logging is useful primarily to developers or when trying to hunt down an i
     )
     cmdstanpy_logger.addHandler(handler)
 
+
 Now, if we run the model and check the contents of the file, we will see all the possible logging.
 
 .. ipython:: python


Test patch:
diff --git a/test/test_logging.py b/test/test_logging.py
new file mode 100644
index 0000000..67a15a8
--- /dev/null
+++ b/test/test_logging.py
@@ -0,0 +1,67 @@
+"""Logging control tests"""
+
+import logging
+import cmdstanpy
+
+
+def test_disable_logging(caplog):
+    logger = cmdstanpy.utils.logging.get_logger()
+
+    with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+        logger.info("before")
+    assert any("before" in m for m in caplog.messages)
+
+    caplog.clear()
+    cmdstanpy.disable_logging()
+
+    with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+        logger.info("after")
+
+    assert not caplog.messages
+    logger.disabled = False
+
+
+def test_disable_logging_context_manager(caplog):
+    logger = cmdstanpy.utils.logging.get_logger()
+
+    with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+        logger.info("before")
+    assert any("before" in m for m in caplog.messages)
+
+    caplog.clear()
+    with cmdstanpy.disable_logging():
+        with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+            logger.info("inside context manager")
+
+    assert not caplog.messages
+
+    with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+        logger.info("after")
+
+    assert any("after" in m for m in caplog.messages)
+    logger.disabled = False
+
+
+def test_disable_logging_context_manager_nested(caplog):
+    logger = cmdstanpy.utils.logging.get_logger()
+
+    with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+        logger.info("before")
+    assert any("before" in m for m in caplog.messages)
+
+    caplog.clear()
+    with cmdstanpy.disable_logging():
+        with cmdstanpy.enable_logging():
+            with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+                logger.info("inside context manager")
+
+    assert any("inside context manager" in m for m in caplog.messages)
+
+    caplog.clear()
+    with cmdstanpy.enable_logging():
+        with cmdstanpy.disable_logging():
+            with caplog.at_level(logging.INFO, logger="cmdstanpy"):
+                logger.info("inside context manager")
+
+    assert not caplog.messages
+    logger.disabled = False