From 6ca9aa2960623489aaf60324b4709848598aec91 Mon Sep 17 00:00:00 2001 From: "John L. Villalovos" Date: Sun, 6 Feb 2022 11:37:51 -0800 Subject: [PATCH] chore: create a custom `warnings.warn` wrapper Create a custom `warnings.warn` wrapper that will walk the stack trace to find the first frame outside of the `gitlab/` path to print the warning against. This will make it easier for users to find where in their code the error is generated from --- gitlab/__init__.py | 13 +++++++----- gitlab/utils.py | 38 +++++++++++++++++++++++++++++++++- gitlab/v4/objects/artifacts.py | 11 +++++----- gitlab/v4/objects/projects.py | 21 +++++++++++-------- tests/unit/test_utils.py | 19 +++++++++++++++++ 5 files changed, 82 insertions(+), 20 deletions(-) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 5f168acb2..8cffecd62 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -20,6 +20,7 @@ from typing import Any import gitlab.config # noqa: F401 +from gitlab import utils as _utils from gitlab._version import ( # noqa: F401 __author__, __copyright__, @@ -40,11 +41,13 @@ def __getattr__(name: str) -> Any: # Deprecate direct access to constants without namespace if name in gitlab.const._DEPRECATED: - warnings.warn( - f"\nDirect access to 'gitlab.{name}' is deprecated and will be " - f"removed in a future major python-gitlab release. Please " - f"use 'gitlab.const.{name}' instead.", - DeprecationWarning, + _utils.warn( + message=( + f"\nDirect access to 'gitlab.{name}' is deprecated and will be " + f"removed in a future major python-gitlab release. Please " + f"use 'gitlab.const.{name}' instead." + ), + category=DeprecationWarning, ) return getattr(gitlab.const, name) raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/gitlab/utils.py b/gitlab/utils.py index 7b01d178d..197935549 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -15,8 +15,11 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import pathlib +import traceback import urllib.parse -from typing import Any, Callable, Dict, Optional, Union +import warnings +from typing import Any, Callable, Dict, Optional, Type, Union import requests @@ -90,3 +93,36 @@ def __new__( # type: ignore def remove_none_from_dict(data: Dict[str, Any]) -> Dict[str, Any]: return {k: v for k, v in data.items() if v is not None} + + +def warn( + message: str, + *, + category: Optional[Type] = None, + source: Optional[Any] = None, +) -> None: + """This `warnings.warn` wrapper function attempts to show the location causing the + warning in the user code that called the library. + + It does this by walking up the stack trace to find the first frame located outside + the `gitlab/` directory. This is helpful to users as it shows them their code that + is causing the warning. + """ + # Get `stacklevel` for user code so we indicate where issue is in + # their code. + pg_dir = pathlib.Path(__file__).parent.resolve() + stack = traceback.extract_stack() + stacklevel = 1 + warning_from = "" + for stacklevel, frame in enumerate(reversed(stack), start=1): + if stacklevel == 2: + warning_from = f" (python-gitlab: {frame.filename}:{frame.lineno})" + frame_dir = str(pathlib.Path(frame.filename).parent.resolve()) + if not frame_dir.startswith(str(pg_dir)): + break + warnings.warn( + message=message + warning_from, + category=category, + stacklevel=stacklevel, + source=source, + ) diff --git a/gitlab/v4/objects/artifacts.py b/gitlab/v4/objects/artifacts.py index dee28804e..55d762be1 100644 --- a/gitlab/v4/objects/artifacts.py +++ b/gitlab/v4/objects/artifacts.py @@ -2,7 +2,6 @@ GitLab API: https://docs.gitlab.com/ee/api/job_artifacts.html """ -import warnings from typing import Any, Callable, Optional, TYPE_CHECKING import requests @@ -34,10 +33,12 @@ def __call__( *args: Any, **kwargs: Any, ) -> Optional[bytes]: - warnings.warn( - "The project.artifacts() method is deprecated and will be " - "removed in a future version. Use project.artifacts.download() instead.\n", - DeprecationWarning, + utils.warn( + message=( + "The project.artifacts() method is deprecated and will be removed in a " + "future version. Use project.artifacts.download() instead.\n" + ), + category=DeprecationWarning, ) return self.download( *args, diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index d1e993b4c..81eb62496 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -1,4 +1,3 @@ -import warnings from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING, Union import requests @@ -548,10 +547,12 @@ def transfer(self, to_namespace: Union[int, str], **kwargs: Any) -> None: @cli.register_custom_action("Project", ("to_namespace",)) def transfer_project(self, *args: Any, **kwargs: Any) -> None: - warnings.warn( - "The project.transfer_project() method is deprecated and will be " - "removed in a future version. Use project.transfer() instead.", - DeprecationWarning, + utils.warn( + message=( + "The project.transfer_project() method is deprecated and will be " + "removed in a future version. Use project.transfer() instead." + ), + category=DeprecationWarning, ) return self.transfer(*args, **kwargs) @@ -562,10 +563,12 @@ def artifact( *args: Any, **kwargs: Any, ) -> Optional[bytes]: - warnings.warn( - "The project.artifact() method is deprecated and will be " - "removed in a future version. Use project.artifacts.raw() instead.", - DeprecationWarning, + utils.warn( + message=( + "The project.artifact() method is deprecated and will be " + "removed in a future version. Use project.artifacts.raw() instead." + ), + category=DeprecationWarning, ) return self.artifacts.raw(*args, **kwargs) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 9f909830d..7641c6979 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import json +import warnings from gitlab import utils @@ -76,3 +77,21 @@ def test_json_serializable(self): obj = utils.EncodedId("we got/a/path") assert '"we%20got%2Fa%2Fpath"' == json.dumps(obj) + + +class TestWarningsWrapper: + def test_warn(self): + warn_message = "short and stout" + warn_source = "teapot" + + with warnings.catch_warnings(record=True) as caught_warnings: + utils.warn(message=warn_message, category=UserWarning, source=warn_source) + assert len(caught_warnings) == 1 + warning = caught_warnings[0] + # File name is this file as it is the first file outside of the `gitlab/` path. + assert __file__ == warning.filename + assert warning.category == UserWarning + assert isinstance(warning.message, UserWarning) + assert warn_message in str(warning.message) + assert __file__ in str(warning.message) + assert warn_source == warning.source