Skip to content

Commit 1a94911

Browse files
authored
Fix #362: don't reconfigure root logger when importing pyensembl.shell (#363)
* Fix #362: don't reconfigure root logger when importing pyensembl.shell pyensembl/shell.py called logging.config.fileConfig() at module top-level. fileConfig defaults to disable_existing_loggers=True, so importing the module (a) attached pyensembl's console handlers to the root logger and set its level to INFO, and (b) disabled every logger the host application had created before the import. A library must not configure the root logger or disable other loggers as an import side effect. Following the Python logging HOWTO ("Configuring Logging for a Library"): - Move the fileConfig() call into a configure_logging() helper that is only invoked from the CLI entrypoint (run()), and pass disable_existing_loggers=False so it never disables application loggers. - Attach a NullHandler to the "pyensembl" package logger in __init__.py so library use emits nothing by default and never warns about missing handlers, while leaving the root logger untouched. Adds regression tests covering the import side-effects and the CLI logging setup. Claude-Session: https://claude.ai/code/session_016pKPSmCAts4cacC3CQ9qsL * test: restore global logging state in configure_logging test configure_logging() applies logging.conf, which mutates process-global logging state (replaces the pyensembl logger's NullHandler with a live console handler and reconfigures root). Snapshot and restore that state in test_configure_logging_preserves_existing_loggers so it doesn't leak a console handler into sibling tests running in the same worker. Claude-Session: https://claude.ai/code/session_016pKPSmCAts4cacC3CQ9qsL
1 parent 834ff46 commit 1a94911

3 files changed

Lines changed: 101 additions & 1 deletion

File tree

‎pyensembl/__init__.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
import logging
14+
1315
from .database import Database
1416
from .download_cache import DownloadCache
1517
from .ensembl_release import EnsemblRelease, cached_release
@@ -35,6 +37,12 @@
3537
from .transcript import Transcript
3638
from .version import __version__
3739

40+
# Per the Python logging HOWTO ("Configuring Logging for a Library"), attach a
41+
# no-op handler to the package logger so that library usage neither emits log
42+
# output nor triggers "No handlers could be found" warnings, while leaving the
43+
# root logger and any application-configured loggers untouched.
44+
logging.getLogger(__name__).addHandler(logging.NullHandler())
45+
3846
__all__ = [
3947
"__version__",
4048
"DownloadCache",

‎pyensembl/shell.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,23 @@
4949
from .species import Species
5050
from .version import __version__
5151

52-
logging.config.fileConfig(str(resources.files("pyensembl") / "logging.conf"))
5352
logger = logging.getLogger(__name__)
5453

5554

55+
def configure_logging():
56+
"""Apply pyensembl's console logging configuration.
57+
58+
This is only invoked from the command-line entrypoint (``run``) so that
59+
merely importing this module never reconfigures the root logger or
60+
disables any loggers the host application has already created. See
61+
https://github.com/openvax/pyensembl/issues/362.
62+
"""
63+
logging.config.fileConfig(
64+
str(resources.files("pyensembl") / "logging.conf"),
65+
disable_existing_loggers=False,
66+
)
67+
68+
5669
parser = argparse.ArgumentParser(usage=__doc__)
5770

5871
parser.add_argument(
@@ -365,6 +378,7 @@ def _w(values, fallback):
365378

366379

367380
def run():
381+
configure_logging()
368382
args = parser.parse_args()
369383
if args.action == "list":
370384
# TODO: how do we also identify which non-Ensembl genomes are

‎tests/test_shell.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import logging
2+
import subprocess
3+
import sys
4+
15
from pyensembl.shell import (
26
all_combinations_of_ensembl_genomes,
7+
configure_logging,
38
format_available_species,
49
parser,
510
)
@@ -66,3 +71,76 @@ def test_format_available_species_collapses_single_release():
6671
)
6772
assert "54–54" not in ncbi36_line
6873
assert "54" in ncbi36_line
74+
75+
76+
# Regression test for https://github.com/openvax/pyensembl/issues/362:
77+
# importing pyensembl / pyensembl.shell must not reconfigure the root logger
78+
# or disable loggers the host application created before the import. Run in a
79+
# fresh interpreter because logging state is process-global and modules are
80+
# only imported once.
81+
_IMPORT_SIDE_EFFECT_PROBE = """
82+
import logging
83+
84+
created_before = logging.getLogger("created_before")
85+
86+
import pyensembl
87+
import pyensembl.shell
88+
89+
# pyensembl's logging.conf attaches a CRITICAL-level StreamHandler to the root
90+
# logger; it must not be applied merely by importing the package.
91+
root = logging.getLogger()
92+
pyensembl_root_handlers = [
93+
h for h in root.handlers if getattr(h, "level", None) == logging.CRITICAL
94+
]
95+
assert not pyensembl_root_handlers, (
96+
"import added pyensembl's console handler to the root logger: %r"
97+
% (pyensembl_root_handlers,)
98+
)
99+
100+
# A logger created before the import must not be disabled
101+
# (fileConfig(disable_existing_loggers=True) would have disabled it).
102+
assert created_before.disabled is False, "import disabled a pre-existing logger"
103+
104+
# The package logger should carry a NullHandler so library use neither emits
105+
# output nor triggers "No handlers could be found" warnings.
106+
assert any(
107+
isinstance(h, logging.NullHandler)
108+
for h in logging.getLogger("pyensembl").handlers
109+
), "pyensembl package logger is missing a NullHandler"
110+
111+
print("ok")
112+
"""
113+
114+
115+
def test_import_does_not_reconfigure_root_logger():
116+
result = subprocess.run(
117+
[sys.executable, "-c", _IMPORT_SIDE_EFFECT_PROBE],
118+
capture_output=True,
119+
text=True,
120+
)
121+
assert result.returncode == 0, result.stderr
122+
assert result.stdout.strip().endswith("ok")
123+
124+
125+
def test_configure_logging_preserves_existing_loggers():
126+
# configure_logging() applies logging.conf, which mutates process-global
127+
# logging state (root + pyensembl loggers). Snapshot and restore it so this
128+
# test doesn't leak a live console handler into sibling tests.
129+
root = logging.getLogger()
130+
pyensembl_logger = logging.getLogger("pyensembl")
131+
saved_root_handlers = root.handlers[:]
132+
saved_root_level = root.level
133+
saved_pyensembl_handlers = pyensembl_logger.handlers[:]
134+
try:
135+
created_before = logging.getLogger("test_configure_logging_preexisting")
136+
created_before.disabled = False
137+
configure_logging()
138+
# The CLI entrypoint applies logging.conf, but with
139+
# disable_existing_loggers=False so it leaves other loggers alone.
140+
assert created_before.disabled is False
141+
# pyensembl's own logger should be wired up to a handler for CLI output.
142+
assert pyensembl_logger.handlers
143+
finally:
144+
root.handlers[:] = saved_root_handlers
145+
root.level = saved_root_level
146+
pyensembl_logger.handlers[:] = saved_pyensembl_handlers

0 commit comments

Comments
 (0)