Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DM-39628: Add find_outside_stacklevel function #161

Merged
merged 4 commits into from
Jun 20, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/changes/DM-39628.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added ``lsst.utils.introspection.find_outside_stacklevel``.
This function can be used to calculate the stack level that should be passed to warnings and log messages in order to make them look like they came from the line outside the specified package in user code.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ convention = "numpy"
# Docstring at the very first line is not required
# D200, D205 and D400 all complain if the first sentence of the docstring does
# not fit on one line.
add-ignore = ["E133", "E226", "E228", "N802", "N803", "N806", "N812", "N815", "N816", "W503", "E203"]
add-ignore = ["D107", "D105", "D102", "D100", "D200", "D205", "D400"]

[tool.coverage.report]
show_missing = true
Expand Down
2 changes: 2 additions & 0 deletions python/lsst/utils/ellipsis.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
from enum import Enum

class EllipsisType(Enum):
"""The type associated with an `...`"""

Ellipsis = "..."

Ellipsis = EllipsisType.Ellipsis
Expand Down
50 changes: 49 additions & 1 deletion python/lsst/utils/introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@

"""Utilities relating to introspection in python."""

__all__ = ["get_class_of", "get_full_type_name", "get_instance_of", "get_caller_name"]
__all__ = [
"get_class_of",
"get_full_type_name",
"get_instance_of",
"get_caller_name",
"find_outside_stacklevel",
]

import builtins
import inspect
Expand Down Expand Up @@ -184,3 +190,45 @@
if codename != "<module>": # top level usually
name.append(codename) # function or a method
return ".".join(name)


def find_outside_stacklevel(module_name: str) -> int:
"""Find the stacklevel for outside of the given module.

This can be used to determine the stacklevel parameter that should be
passed to log messages or warnings in order to make them appear to
come from external code and not this package.

Parameters
----------
module_name : `str`
The name of the module to base the stack level calculation upon.

Returns
-------
stacklevel : `int`
The stacklevel to use matching the first stack frame outside of the
given module.
"""
this_module = "lsst.utils"
stacklevel = -1
for i, s in enumerate(inspect.stack()):
module = inspect.getmodule(s.frame)
# Stack frames sometimes hang around so explicitly delete.
del s
if module is None:
continue

Check warning on line 220 in python/lsst/utils/introspection.py

View check run for this annotation

Codecov / codecov/patch

python/lsst/utils/introspection.py#L220

Added line #L220 was not covered by tests
if module_name != this_module and module.__name__.startswith(this_module):
# Should not include this function unless explicitly requested.
continue
if not module.__name__.startswith(module_name):
# 0 will be this function.
# 1 will be the caller
# and so does not need adjustment.
stacklevel = i
break
else:
# The top can't be inside the module.
stacklevel = i

Check warning on line 232 in python/lsst/utils/introspection.py

View check run for this annotation

Codecov / codecov/patch

python/lsst/utils/introspection.py#L232

Added line #L232 was not covered by tests

return stacklevel
43 changes: 2 additions & 41 deletions python/lsst/utils/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

import datetime
import functools
import inspect
import logging
import time
from contextlib import contextmanager
Expand All @@ -37,6 +36,7 @@

from astropy import units as u

from .introspection import find_outside_stacklevel
from .usage import _get_current_rusage, get_current_mem_usage, get_peak_mem_usage

if TYPE_CHECKING:
Expand Down Expand Up @@ -80,45 +80,6 @@ def _add_to_metadata(metadata: MutableMapping, name: str, value: Any) -> None:
metadata[name].append(value)


def _find_outside_stacklevel() -> int:
"""Find the stack level corresponding to caller code outside of this
module.

This can be passed directly to `logging.Logger.log()` to ensure
that log messages are issued as if they are coming from caller code.

Returns
-------
stacklevel : `int`
The stack level to use to refer to a caller outside of this module.
A ``stacklevel`` of ``1`` corresponds to the caller of this internal
function and that is the default expected by `logging.Logger.log()`.

Notes
-----
Intended to be called from the function that is going to issue a log
message. The result should be passed into `~logging.Logger.log` via the
keyword parameter ``stacklevel``.
"""
stacklevel = 1 # the default for `Logger.log`
for i, s in enumerate(inspect.stack()):
module = inspect.getmodule(s.frame)
if module is None:
# Stack frames sometimes hang around so explicilty delete.
del s
continue
if not module.__name__.startswith("lsst.utils"):
# 0 will be this function.
# 1 will be the caller which will be the default for `Logger.log`
# and so does not need adjustment.
stacklevel = i
break
# Stack frames sometimes hang around so explicilty delete.
del s

return stacklevel


def logPairs(
obj: Any,
pairs: Collection[Tuple[str, Any]],
Expand Down Expand Up @@ -179,7 +140,7 @@ def logPairs(
timer_logger = logging.getLogger("timer." + logger.name)
if timer_logger.isEnabledFor(logLevel):
if stacklevel is None:
stacklevel = _find_outside_stacklevel()
stacklevel = find_outside_stacklevel("lsst.utils")
else:
# Account for the caller stack.
stacklevel += 1
Expand Down
14 changes: 14 additions & 0 deletions tests/import_test/two/three/success.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,17 @@ def okay():
class Container:
def inside():
return "1"

@classmethod
def level(cls):
import warnings

from lsst.utils.introspection import find_outside_stacklevel

stacklevel = find_outside_stacklevel("import_test")
warnings.warn(f"Using stacklevel={stacklevel} in Container class", stacklevel=stacklevel)
return stacklevel

@classmethod
def indirect_level(cls):
return cls.level()
22 changes: 21 additions & 1 deletion tests/test_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
import lsst.utils
from lsst.utils import doImport
from lsst.utils._packaging import getPackageDir
from lsst.utils.introspection import get_caller_name, get_class_of, get_full_type_name, get_instance_of
from lsst.utils.introspection import (
find_outside_stacklevel,
get_caller_name,
get_class_of,
get_full_type_name,
get_instance_of,
)


class GetCallerNameTestCase(unittest.TestCase):
Expand Down Expand Up @@ -112,6 +118,20 @@ def testGetInstanceOf(self):
get_instance_of(lsst.utils)
self.assertIn("lsst.utils", str(cm.exception))

def test_stacklevel(self):
level = find_outside_stacklevel("lsst.utils")
self.assertEqual(level, 1)

c = doImport("import_test.two.three.success.Container")
with self.assertWarns(Warning) as cm:
level = c.level()
self.assertTrue(cm.filename.endswith("test_introspection.py"))
self.assertEqual(level, 2)
with self.assertWarns(Warning) as cm:
level = c.indirect_level()
self.assertTrue(cm.filename.endswith("test_introspection.py"))
self.assertEqual(level, 3)


if __name__ == "__main__":
unittest.main()