Index: python-pyvista/.pre-commit-config.yaml
===================================================================
--- python-pyvista.orig/.pre-commit-config.yaml	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/.pre-commit-config.yaml	2026-01-30 19:21:37.464371794 +0100
@@ -68,6 +68,17 @@
     hooks:
       - id: check-github-workflows
 
+  - repo: local
+    hooks:
+      - id: warn_external
+        name: Convert warnings to warn_external
+        language: python
+        entry: python -m libcst.tool codemod -x hooks.warnings.ConvertWarningsToExternal --no-format
+        additional_dependencies: [libcst]
+        files: ^pyvista/
+        types: [file, python]
+        exclude: pyvista/_warn_external.py
+
   - repo: https://github.com/astral-sh/ruff-pre-commit
     rev: v0.12.7
     hooks:
Index: python-pyvista/CONTRIBUTING.rst
===================================================================
--- python-pyvista.orig/CONTRIBUTING.rst	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/CONTRIBUTING.rst	2026-01-30 19:21:37.464522632 +0100
@@ -385,13 +385,13 @@
 software and scripts.
 
 Here's an example of a soft deprecation of a function. Note the usage of both
-the ``PyVistaDeprecationWarning`` warning and the ``.. deprecated`` Sphinx
-directive.
+the ``PyVistaDeprecationWarning`` warning, the ``.. deprecated`` Sphinx
+directive and the ``warn_external`` helper function.
 
 .. code-block:: python
 
-    import warnings
     from pyvista.core.errors import PyVistaDeprecationWarning
+    from pyvista._warn_external import warn_external  # available from 0.47
 
 
     def addition(a, b):
@@ -415,7 +415,7 @@
 
         """
         # deprecated 0.37.0, convert to error in 0.40.0, remove 0.41.0
-        warnings.warn(
+        warn_external(
             '`addition` has been deprecated. Use pyvista.add instead',
             PyVistaDeprecationWarning,
         )
Index: python-pyvista/hooks/warnings.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ python-pyvista/hooks/warnings.py	2026-01-30 19:21:37.464710820 +0100
@@ -0,0 +1,86 @@
+"""Enforce warnings style.
+
+Python script to enforce using the custom `warn_external` function instead of
+plain `warnings.warn`, to allow for dynamic stacklevel value.
+"""
+
+from __future__ import annotations
+
+from inspect import Parameter
+from inspect import Signature
+import sys
+
+import libcst as cst
+from libcst.codemod import VisitorBasedCodemodCommand
+from libcst.codemod.visitors import AddImportsVisitor
+from libcst.codemod.visitors import RemoveImportsVisitor
+import libcst.matchers as m
+
+
+def needs_replace(node: cst.Call) -> bool:  # noqa: D103
+    return m.matches(
+        node.func,
+        m.Attribute(value=m.Name('warnings'), attr=m.Name('warn')) | m.Name('warn'),
+    )
+
+
+def get_args_kwargs(args: tuple[cst.Arg]) -> tuple[list[cst.Arg], dict[str, cst.Arg]]:  # noqa: D103
+    a = [a for a in args if a.keyword is None]
+    kw = {kw.value: a for a in args if (kw := a.keyword) is not None}
+    return a, kw
+
+
+# Need to manually build the `warnings.warn` signature because `inspect.signature`
+# is raising an error for some builtins https://github.com/python/cpython/issues/123473
+_WARN_PARAMS = [
+    Parameter(
+        name='message',
+        kind=Parameter.POSITIONAL_OR_KEYWORD,
+    ),
+    Parameter(name='category', kind=Parameter.POSITIONAL_OR_KEYWORD, default=None),
+    Parameter(name='stacklevel', kind=Parameter.POSITIONAL_OR_KEYWORD, default=1),
+    Parameter(name='source', kind=Parameter.POSITIONAL_OR_KEYWORD, default=None),
+]
+
+if sys.version_info[:2] >= (3, 12):
+    _WARN_PARAMS.append(
+        Parameter(name='skip_file_prefixes', kind=Parameter.KEYWORD_ONLY, default=())
+    )
+
+
+_WARN_SIGNATURE = Signature(parameters=_WARN_PARAMS)
+
+
+class ConvertWarningsToExternal(VisitorBasedCodemodCommand):
+    """Class responsible to parse/modify the syntax tree if warnings calls are found."""
+
+    def leave_Call(  # noqa: D102, N802
+        self,
+        original_node: cst.Call,
+        updated_node: cst.Call,
+    ) -> cst.Call:
+        if needs_replace(original_node):
+            AddImportsVisitor.add_needed_import(
+                self.context,
+                module='pyvista._warn_external',
+                obj='warn_external',
+            )
+            RemoveImportsVisitor.remove_unused_import(self.context, 'warnings')
+
+            # Remove all except `category` and `message` from call.
+            # Relies on the fact that pos args are passed before keyword args
+            a, kw = get_args_kwargs(original_node.args)
+            bound = _WARN_SIGNATURE.bind(*a, **kw)
+            b_arguments = bound.arguments
+            b_arguments = {a: v for a, v in b_arguments.items() if a in ['category', 'message']}
+
+            args: list[cst.Arg] = list(b_arguments.values())
+
+            # Remove trailing comma
+            args[-1] = args[-1].with_changes(comma=cst.MaybeSentinel.DEFAULT)
+
+            return updated_node.with_changes(
+                func=cst.Name('warn_external'),
+                args=args,
+            )
+        return updated_node
Index: python-pyvista/pyproject.toml
===================================================================
--- python-pyvista.orig/pyproject.toml	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyproject.toml	2026-01-30 19:21:37.464891071 +0100
@@ -79,6 +79,7 @@
   'imageio<2.38.0',
   'ipython<10.0.0',
   'ipywidgets<9.0.0',
+  'libcst<1.8.7',
   'meshio<5.4.0',
   'nest_asyncio<1.6.1',
   'numpydoc<1.10.0',
@@ -444,6 +445,7 @@
 'examples/99-advanced/warp_by_vector_eigenmodes.py' = ['N802', 'N803']
 'examples_trame/*' = ['D100', 'D103', 'T20']
 'examples_trame/tests/*' = ['D']
+'hooks/*' = ['INP001']
 'pyvista/__init__.py' = ['ANN']
 'pyvista/_plot.py' = ['ANN']
 'pyvista/conftest.py' = ['ANN']
Index: python-pyvista/pyvista/_deprecate_positional_args.py
===================================================================
--- python-pyvista.orig/pyvista/_deprecate_positional_args.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/_deprecate_positional_args.py	2026-01-30 19:21:37.465000197 +0100
@@ -9,11 +9,11 @@
 from typing import Callable
 from typing import TypeVar
 from typing import overload
-import warnings
 
 from typing_extensions import ParamSpec
 
 from pyvista._version import version_info
+from pyvista._warn_external import warn_external
 
 _MAX_POSITIONAL_ARGS = 3  # Should match value in pyproject.toml
 
@@ -238,7 +238,7 @@
                             f'From version {version_str}, passing {this} as{a}positional '
                             f'argument{s} will result in a TypeError.'
                         )
-                        warnings.warn(msg, PyVistaDeprecationWarning, stacklevel=stack_level)
+                        warn_external(msg, PyVistaDeprecationWarning)
 
                     warn_positional_args()
 
Index: python-pyvista/pyvista/_warn_external.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ python-pyvista/pyvista/_warn_external.py	2026-01-30 19:21:37.465076920 +0100
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import itertools
+import pathlib
+import re
+import sys
+import warnings
+
+
+def warn_external(message: str, category: type[Warning] | None = None) -> None:
+    """`warnings.warn` wrapper that sets *stacklevel* to "outside PyVista".
+
+    Taken and modified from Matplotlib
+    https://github.com/matplotlib/matplotlib/blob/db83efff4d7d3849f8bffbd1f6cdfc43d74c9aea/lib/matplotlib/_api/__init__.py#L395
+
+    """
+    kwargs = {}
+    if sys.version_info[:2] >= (3, 12):
+        # Go to Python's `site-packages` or `pyvista` from an editable install.
+        basedir = pathlib.Path(__file__).parents[1]
+        kwargs['skip_file_prefixes'] = (str(basedir / 'pyvista'),)
+    else:
+        frame = sys._getframe()
+        for stacklevel in itertools.count(1):
+            if frame is None:
+                # when called in embedded context may hit frame is None
+                kwargs['stacklevel'] = stacklevel
+                break
+            if not re.match(
+                r'\Apyvista(\Z|\.(?!tests\.))',
+                # Work around sphinx-gallery not setting __name__.
+                frame.f_globals.get('__name__', ''),
+            ):
+                kwargs['stacklevel'] = stacklevel
+                break
+            frame = frame.f_back
+        # preemptively break reference cycle between locals and the frame
+        del frame
+
+    warnings.warn(message, category, **kwargs)  # type: ignore[call-overload]
Index: python-pyvista/pyvista/core/_vtk_core.py
===================================================================
--- python-pyvista.orig/pyvista/core/_vtk_core.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/_vtk_core.py	2026-01-30 19:26:12.089901530 +0100
@@ -12,7 +12,6 @@
 import contextlib
 import sys
 from typing import NamedTuple
-import warnings
 
 from vtkmodules.vtkCommonCore import vtkInformation as vtkInformation
 from vtkmodules.vtkCommonCore import vtkVersion as vtkVersion
@@ -473,6 +472,8 @@
 from vtkmodules.vtkFiltersSources import vtkArcSource as vtkArcSource
 from vtkmodules.vtkFiltersSources import vtkArrowSource as vtkArrowSource
 
+from pyvista._warn_external import warn_external
+
 with contextlib.suppress(ImportError):
     # Deprecated in 9.3
     from vtkmodules.vtkFiltersSources import (  # type: ignore[attr-defined]
@@ -648,7 +649,7 @@
         minor = ver.GetVTKMinorVersion()
         micro = ver.GetVTKBuildVersion()
     except AttributeError:  # pragma: no cover
-        warnings.warn('Unable to detect VTK version. Defaulting to v4.0.0')
+        warn_external('Unable to detect VTK version. Defaulting to v4.0.0')
         major, minor, micro = (4, 0, 0)
 
     return VersionInfo(major, minor, micro)
@@ -706,7 +707,7 @@
                     if state == 'error':
                         raise pv.PyVistaAttributeError(msg)
                     else:
-                        warnings.warn(msg, RuntimeWarning)
+                        warn_external(msg, RuntimeWarning)
 
     def __getattribute__(self, item):
         DisableVtkSnakeCase.check_attribute(self, item)
Index: python-pyvista/pyvista/core/dataset.py
===================================================================
--- python-pyvista.orig/pyvista/core/dataset.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/dataset.py	2026-01-30 19:27:39.171104774 +0100
@@ -12,12 +12,12 @@
 from typing import NamedTuple
 from typing import cast
 from typing import overload
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.typing.mypy_plugin import promote_type
 
 from . import _vtk_core as _vtk
@@ -120,7 +120,7 @@
         self.association = association
         self.name = name
         # Deprecated on v0.45.0, estimated removal on v0.48.0
-        warnings.warn(
+        warn_external(
             'ActiveArrayInfo is deprecated. Use ActiveArrayInfoTuple instead.',
             PyVistaDeprecationWarning,
         )
Index: python-pyvista/pyvista/core/filters/data_object.py
===================================================================
--- python-pyvista.orig/pyvista/core/filters/data_object.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/filters/data_object.py	2026-01-30 19:30:49.708606612 +0100
@@ -9,13 +9,13 @@
 from typing import Literal
 from typing import TypeVar
 from typing import cast
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
 from pyvista._version import version_info
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 from pyvista.core import _vtk_core as _vtk
 from pyvista.core.errors import PyVistaDeprecationWarning
@@ -177,7 +177,7 @@
                 'Previously it defaulted to `True`, but will change to `False`. '
                 'Explicitly set `inplace` to `True` or `False` to silence this warning.'
             )
-            warnings.warn(msg, PyVistaDeprecationWarning)
+            warn_external(msg, PyVistaDeprecationWarning)
             inplace = True  # The old default behavior
 
         if isinstance(self, pyvista.MultiBlock):
@@ -232,7 +232,7 @@
                     dataset_attrs[vector_name] = vector_arr.astype(np.float32)
                     converted_ints = True
         if converted_ints:
-            warnings.warn(
+            warn_external(
                 'Integer points, vector and normal data (if any) of the input mesh '
                 'have been converted to ``np.float32``. This is necessary in order '
                 'to transform properly.',
@@ -282,7 +282,7 @@
                     'not supported\nby RectilinearGrid; cast to StructuredGrid first to support '
                     'shear transformations.'
                 )
-                warnings.warn(msg)
+                warn_external(msg)
 
             # Lump scale and reflection together
             scale = S * N
@@ -292,7 +292,7 @@
                     'removed. Rotation is\nnot supported by RectilinearGrid; cast to '
                     'StructuredGrid first to fully support rotations.'
                 )
-                warnings.warn(msg)
+                warn_external(msg)
             else:
                 # Lump any reflections from the rotation into the scale
                 scale *= np.diagonal(R)
Index: python-pyvista/pyvista/core/filters/data_set.py
===================================================================
--- python-pyvista.orig/pyvista/core/filters/data_set.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/filters/data_set.py	2026-01-30 19:32:34.458030187 +0100
@@ -12,12 +12,12 @@
 from typing import Callable
 from typing import Literal
 from typing import cast
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 import pyvista.core._vtk_core as _vtk
 from pyvista.core.errors import AmbiguousDataError
@@ -1766,10 +1766,10 @@
             try:
                 set_default_active_scalars(self)
             except MissingDataError:
-                warnings.warn('No data to use for scale. scale will be set to False.')
+                warn_external('No data to use for scale. scale will be set to False.')
                 do_scale = False
             except AmbiguousDataError as err:
-                warnings.warn(
+                warn_external(
                     f'{err}\nIt is unclear which one to use. scale will be set to False.'
                 )
                 do_scale = False
@@ -1799,12 +1799,12 @@
             try:
                 set_default_active_vectors(dataset)
             except MissingDataError:
-                warnings.warn(
+                warn_external(
                     'No vector-like data to use for orient. orient will be set to False.'
                 )
                 orient = False
             except AmbiguousDataError as err:
-                warnings.warn(
+                warn_external(
                     f'{err}\nIt is unclear which one to use. orient will be set to False.',
                 )
                 orient = False
@@ -2070,7 +2070,7 @@
         # Deprecated on v0.43.0
         keep_largest = kwargs.pop('largest', False)
         if keep_largest:  # pragma: no cover
-            warnings.warn(
+            warn_external(
                 "Use of `largest=True` is deprecated. Use 'largest' or "
                 "`extraction_mode='largest'` instead.",
                 PyVistaDeprecationWarning,
@@ -3196,12 +3196,12 @@
 
         if max_time is not None:
             if max_length is not None:
-                warnings.warn(
+                warn_external(
                     '``max_length`` and ``max_time`` provided. Ignoring deprecated ``max_time``.',
                     PyVistaDeprecationWarning,
                 )
             else:
-                warnings.warn(
+                warn_external(
                     '``max_time`` parameter is deprecated.  It will be removed in v0.48',
                     PyVistaDeprecationWarning,
                 )
@@ -5469,7 +5469,7 @@
                 msg += '\nIts value cannot be False for vtk>=9.5.0.'
                 raise ValueError(msg)
             else:
-                warnings.warn(msg, pyvista.PyVistaDeprecationWarning)
+                warn_external(msg, pyvista.PyVistaDeprecationWarning)
         elif not vtk_at_least_95:
             # Set default for older VTK:
             main_has_priority = True
@@ -5647,7 +5647,7 @@
             'This filter is deprecated. Use `cell_quality` instead. Note that this\n'
             "new filter does not include an array named ``'CellQuality'`"
         )
-        warnings.warn(msg, PyVistaDeprecationWarning)
+        warn_external(msg, PyVistaDeprecationWarning)
 
         alg = _vtk.vtkCellQuality()
         possible_measure_setters = {
Index: python-pyvista/pyvista/core/filters/image_data.py
===================================================================
--- python-pyvista.orig/pyvista/core/filters/image_data.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/filters/image_data.py	2026-01-30 19:32:56.815057746 +0100
@@ -8,12 +8,12 @@
 from typing import Callable
 from typing import Literal
 from typing import cast
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 from pyvista.core import _vtk_core as _vtk
 from pyvista.core.errors import AmbiguousDataError
@@ -1694,7 +1694,7 @@
             Function used internally by SurfaceNets to generate contiguous label data.
 
         """
-        warnings.warn(
+        warn_external(
             'This filter produces unexpected results and is deprecated. '
             'Use `contour_labels` instead.'
             '\nRefer to the documentation for `contour_labeled` for details on how to '
@@ -3122,14 +3122,14 @@
         # Deprecated on v0.45.0, estimated removal on v0.48.0
         if pad_singleton_dims is not None:
             if pad_singleton_dims:
-                warnings.warn(
+                warn_external(
                     'Use of `pad_singleton_dims=True` is deprecated. '
                     'Use `dimensionality="3D"` instead',
                     PyVistaDeprecationWarning,
                 )
                 dimensionality = '3D'
             else:
-                warnings.warn(
+                warn_external(
                     'Use of `pad_singleton_dims=False` is deprecated. '
                     'Use `dimensionality="preserve"` instead',
                     PyVistaDeprecationWarning,
Index: python-pyvista/pyvista/core/filters/poly_data.py
===================================================================
--- python-pyvista.orig/pyvista/core/filters/poly_data.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/filters/poly_data.py	2026-01-30 19:33:58.591563362 +0100
@@ -5,12 +5,12 @@
 from collections.abc import Sequence
 from typing import TYPE_CHECKING
 from typing import cast
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 from pyvista.core import _vtk_core as _vtk
 from pyvista.core.errors import MissingDataError
@@ -277,7 +277,7 @@
         if bool_inter.is_empty:
             inter, s1, s2 = self.intersection(other_mesh)
             if inter.is_empty and s1.is_empty and s2.is_empty:
-                warnings.warn(
+                warn_external(
                     'Unable to compute boolean intersection when one PolyData is '
                     'contained within another and no faces intersect.',
                 )
@@ -1795,7 +1795,7 @@
 
         has_attribute_error = False if attribute_error is None else attribute_error
         if has_attribute_error:  # pragma: no cover
-            warnings.warn(
+            warn_external(
                 'Since 0.45, use of `attribute_error=True` is deprecated.'
                 "Use 'enable_all_attribute_error' instead.",
                 PyVistaDeprecationWarning,
@@ -1831,7 +1831,7 @@
         alg.SetTargetReduction(target_reduction)
         if pyvista.vtk_version_info < (9, 3, 0):  # pragma: no cover
             if boundary_constraints:
-                warnings.warn('`boundary_constraints` requires vtk >= 9.3.')
+                warn_external('`boundary_constraints` requires vtk >= 9.3.')
         else:
             alg.SetWeighBoundaryConstraintsByLength(boundary_constraints)
             alg.SetBoundaryWeightFactor(boundary_weight)
@@ -2933,7 +2933,7 @@
                 try:
                     newmesh.cell_data[key] = self.cell_data[key][fmask]  # type: ignore[attr-defined]
                 except (ValueError, TypeError, KeyError):  # pragma: no cover
-                    warnings.warn(f'Unable to pass cell key {key} onto reduced mesh')
+                    warn_external(f'Unable to pass cell key {key} onto reduced mesh')
 
         # Return vtk surface and reverse indexing array
         if inplace:
@@ -2961,7 +2961,7 @@
 
         """
         # Deprecated on v0.45.0, estimated removal on v0.48.0
-        warnings.warn(
+        warn_external(
             '`flip_normals` is deprecated. Use `flip_faces` instead. '
             'Note that `inplace` is now `False` by default for the new filter.',
             PyVistaDeprecationWarning,
@@ -3559,7 +3559,7 @@
         """
         if capping is None:
             capping = False
-            warnings.warn(
+            warn_external(
                 'The default value of the ``capping`` keyword argument will change in '
                 'a future version to ``True`` to match the behavior of VTK. We recommend '
                 'passing the keyword explicitly to prevent future surprises.',
@@ -3711,7 +3711,7 @@
         """
         if capping is None:
             capping = False
-            warnings.warn(
+            warn_external(
                 'The default value of the ``capping`` keyword argument will change in '
                 'a future version to ``True`` to match the behavior of VTK. We recommend '
                 'passing the keyword explicitly to prevent future surprises.',
Index: python-pyvista/pyvista/core/grid.py
===================================================================
--- python-pyvista.orig/pyvista/core/grid.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/grid.py	2026-01-30 19:29:05.196487151 +0100
@@ -16,6 +16,7 @@
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 
 if TYPE_CHECKING:
@@ -1029,7 +1030,7 @@
                 'RectilinearGrid.\nThe direction is ignored. Consider casting to StructuredGrid '
                 'instead.'
             )
-            warnings.warn(msg, RuntimeWarning)
+            warn_external(msg, RuntimeWarning)
 
         # Use linspace to avoid rounding error accumulation
         ijk = [np.linspace(offset[i], offset[i] + dims[i] - 1, dims[i]) for i in range(3)]
@@ -1202,7 +1203,7 @@
     ) -> None:  # numpydoc ignore=GL08
         T, R, N, S, K = pyvista.Transform(matrix).decompose()
         if not np.allclose(K, np.eye(3)):
-            warnings.warn(
+            warn_external(
                 'The transformation matrix has a shear component which has been removed. \n'
                 'Shear is not supported when setting `ImageData` `index_to_physical_matrix`.'
             )
Index: python-pyvista/pyvista/core/utilities/features.py
===================================================================
--- python-pyvista.orig/pyvista/core/utilities/features.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/utilities/features.py	2026-01-30 19:34:21.158554638 +0100
@@ -5,12 +5,12 @@
 from collections.abc import Sequence
 import os
 import sys
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _vtk_core as _vtk
 from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.utilities.helpers import wrap
@@ -157,7 +157,7 @@
 
     """
     # Deprecated on v0.46.0, estimated removal on v0.49.0
-    warnings.warn(
+    warn_external(
         '`pyvista.voxelize` is deprecated. Use `pyvista.DataSetFilters.voxelize` instead.',
         PyVistaDeprecationWarning,
     )
@@ -391,7 +391,7 @@
 
     """
     # Deprecated on v0.46.0, estimated removal on v0.49.0
-    warnings.warn(
+    warn_external(
         '`pyvista.voxelize_volume` is deprecated. Use '
         '`pyvista.DataSetFilters.voxelize_rectilinear` instead.',
         PyVistaDeprecationWarning,
Index: python-pyvista/pyvista/core/utilities/fileio.py
===================================================================
--- python-pyvista.orig/pyvista/core/utilities/fileio.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/utilities/fileio.py	2026-01-30 19:34:46.614876691 +0100
@@ -14,12 +14,12 @@
 from typing import Union
 from typing import cast
 from typing import overload
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _vtk_core as _vtk
 from pyvista.core.errors import PyVistaDeprecationWarning
 
@@ -305,7 +305,7 @@
             reader.show_progress()
         mesh = reader.read()
         if observer.has_event_occurred():
-            warnings.warn(
+            warn_external(
                 f'The VTK reader `{reader.reader.GetClassName()}` in pyvista reader `{reader}` '
                 'raised an error while reading the file.\n'
                 f'\t"{observer.get_message()}"',
@@ -327,7 +327,7 @@
         Mapping of methods to call on reader.
 
     """
-    warnings.warn(
+    warn_external(
         'attrs use is deprecated.  Use a Reader class for more flexible control',
         PyVistaDeprecationWarning,
     )
@@ -735,13 +735,13 @@
                 cond1 = grid_unit.startswith(keywords['MAPUNITS'].lower())
 
                 if not cond1:
-                    warnings.warn(
+                    warn_external(
                         'Unable to convert relative coordinates with different '
                         'grid and map units. Skipping conversion.'
                     )
 
             except KeyError:
-                warnings.warn(
+                warn_external(
                     "Unable to convert relative coordinates without keyword 'MAPUNITS'. "
                     'Skipping conversion.'
                 )
@@ -751,7 +751,7 @@
                 origin = keywords['MAPAXES'][2:4]
 
             except KeyError:
-                warnings.warn(
+                warn_external(
                     "Unable to convert relative coordinates without keyword 'MAPAXES'. "
                     'Skipping conversion.'
                 )
Index: python-pyvista/pyvista/core/utilities/misc.py
===================================================================
--- python-pyvista.orig/pyvista/core/utilities/misc.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/utilities/misc.py	2026-01-30 19:35:03.246738160 +0100
@@ -12,11 +12,12 @@
 import traceback
 from typing import TYPE_CHECKING
 from typing import TypeVar
-import warnings
 
 import numpy as np
 from typing_extensions import Self
 
+from pyvista._warn_external import warn_external
+
 if TYPE_CHECKING:
     from typing import Any
 
@@ -220,7 +221,7 @@
         formatted_exception = 'Encountered issue in callback (most recent call last):\n' + ''.join(
             traceback.format_list(stack) + traceback.format_exception_only(etype, exc),
         ).rstrip('\n')
-        warnings.warn(formatted_exception)
+        warn_external(formatted_exception)
 
 
 def threaded(fn):  # noqa: ANN001, ANN201
Index: python-pyvista/pyvista/core/utilities/points.py
===================================================================
--- python-pyvista.orig/pyvista/core/utilities/points.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/core/utilities/points.py	2026-01-30 19:35:22.912233871 +0100
@@ -5,12 +5,12 @@
 from typing import TYPE_CHECKING
 from typing import Literal
 from typing import overload
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 from pyvista.core import _vtk_core as _vtk
 
@@ -76,7 +76,7 @@
             raise
 
     if force_float and not np.issubdtype(points_.dtype, np.floating):
-        warnings.warn(
+        warn_external(
             'Points is not a float type. This can cause issues when '
             'transforming or applying filters. Casting to '
             '``np.float32``. Disable this by passing '
Index: python-pyvista/pyvista/errors.py
===================================================================
--- python-pyvista.orig/pyvista/errors.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/errors.py	2026-01-30 19:40:29.058530999 +0100
@@ -4,7 +4,8 @@
 
 import importlib
 import inspect
-import warnings
+
+from pyvista._warn_external import warn_external
 
 # Places to look for the utility
 _MODULES = [
@@ -64,7 +65,7 @@
         f'`{name}` is now imported as: `{import_path}`.'
     )
 
-    warnings.warn(
+    warn_external(
         message,
         PyVistaDeprecationWarning,
     )
Index: python-pyvista/pyvista/examples/downloads.py
===================================================================
--- python-pyvista.orig/pyvista/examples/downloads.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/examples/downloads.py	2026-01-30 19:35:48.336080922 +0100
@@ -32,7 +32,6 @@
 import shutil
 import sys
 from typing import cast
-import warnings
 
 import numpy as np
 import pooch
@@ -41,6 +40,7 @@
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _vtk_core as _vtk
 from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.errors import VTKVersionError
@@ -85,7 +85,7 @@
 default_user_data_path = str(pooch.os_cache(f'pyvista_{CACHE_VERSION}'))
 if 'PYVISTA_USERDATA_PATH' in os.environ:  # pragma: no cover
     if not (path := Path(os.environ['PYVISTA_USERDATA_PATH'])).is_dir():
-        warnings.warn(f'Ignoring invalid PYVISTA_USERDATA_PATH:\n{path}')
+        warn_external(f'Ignoring invalid PYVISTA_USERDATA_PATH:\n{path}')
         USER_DATA_PATH = default_user_data_path
     else:
         USER_DATA_PATH = str(Path(os.environ['PYVISTA_USERDATA_PATH']))
@@ -101,7 +101,7 @@
                 raise OSError
         except (PermissionError, OSError):
             # Warn, don't raise just in case there's an environment issue.
-            warnings.warn(
+            warn_external(
                 f'Unable to access {USER_DATA_PATH}. Manually specify the PyVista'
                 ' examples cache with the PYVISTA_USERDATA_PATH environment variable.',
             )
@@ -5461,7 +5461,7 @@
 
     """
     # Deprecated on v0.44.0, estimated removal on v0.47.0
-    warnings.warn(
+    warn_external(
         '`download_osmnx_graph` is deprecated and will be removed in v0.47.0. Please use https://github.com/pyvista/pyvista-osmnx.',
         PyVistaDeprecationWarning,
     )
Index: python-pyvista/pyvista/jupyter/notebook.py
===================================================================
--- python-pyvista.orig/pyvista/jupyter/notebook.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/jupyter/notebook.py	2026-01-30 19:36:07.743432377 +0100
@@ -12,10 +12,11 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING
-import warnings
 
 import numpy as np
 
+from pyvista._warn_external import warn_external
+
 if TYPE_CHECKING:
     import io
     from pathlib import Path
@@ -53,7 +54,7 @@
             return show_trame(plotter, mode=backend, **kwargs)
 
     except ImportError as e:
-        warnings.warn(
+        warn_external(
             f'Failed to use notebook backend: \n\n{e}\n\nFalling back to a static output.',
         )
 
Index: python-pyvista/pyvista/plotting/_plotting.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/_plotting.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/_plotting.py	2026-01-30 19:37:34.253667405 +0100
@@ -3,12 +3,12 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core.utilities.arrays import get_array
 from pyvista.core.utilities.misc import assert_empty_kwargs
 
@@ -159,9 +159,9 @@
             # Get array from mesh
             opacity = get_array(mesh, opacity, preference=preference, err=True)
             if np.any(opacity > 1):
-                warnings.warn('Opacity scalars contain values over 1')
+                warn_external('Opacity scalars contain values over 1')
             if np.any(opacity < 0):
-                warnings.warn('Opacity scalars contain values less than 0')
+                warn_external('Opacity scalars contain values less than 0')
             custom_opac = True
         except KeyError:
             # Or get opacity transfer function (e.g. "linear")
Index: python-pyvista/pyvista/plotting/_property.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/_property.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/_property.py	2026-01-30 19:37:42.760833468 +0100
@@ -5,6 +5,7 @@
 import pyvista
 from pyvista import vtk_version_info
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core.utilities.misc import _check_range
 from pyvista.core.utilities.misc import _NoNewAttrMixin
 
@@ -252,7 +253,7 @@
         if vtk_version_info < (9, 3) and edge_opacity is not None:  # pragma: no cover
             import warnings  # noqa: PLC0415
 
-            warnings.warn(
+            warn_external(
                 '`edge_opacity` cannot be used under VTK v9.3.0. '
                 'Try installing VTK v9.3.0 or newer.',
                 UserWarning,
Index: python-pyvista/pyvista/plotting/cube_axes_actor.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/cube_axes_actor.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/cube_axes_actor.py	2026-01-30 19:36:24.432840825 +0100
@@ -5,12 +5,12 @@
 from collections.abc import MutableSequence
 from typing import TYPE_CHECKING
 from typing import cast
-import warnings
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core._typing_core import BoundsTuple
 from pyvista.core.utilities.arrays import convert_string_array
 from pyvista.core.utilities.misc import _BoundsSizeMixin
@@ -340,7 +340,7 @@
                     f'Accepts now a sequence of (x,y) offsets. '
                     f'Setting the x offset to {(x := 0.0)}'
                 )
-                warnings.warn(msg, UserWarning)
+                warn_external(msg, UserWarning)
                 self.SetTitleOffset([x, offset])
             else:
                 self.SetTitleOffset(offset)
@@ -351,7 +351,7 @@
                 f'Setting title_offset with a sequence is only supported from vtk >= 9.3. '
                 f'Considering only the second value (ie. y-offset) of {(y := offset[1])}'
             )
-            warnings.warn(msg, UserWarning)
+            warn_external(msg, UserWarning)
             self.SetTitleOffset(y)  # type: ignore[arg-type]
             return
 
Index: python-pyvista/pyvista/plotting/picking.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/picking.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/picking.py	2026-01-30 19:36:49.480957886 +0100
@@ -4,13 +4,13 @@
 
 from functools import partial
 from functools import wraps
-import warnings
 import weakref
 
 import numpy as np
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.utilities.misc import _NoNewAttrMixin
 from pyvista.core.utilities.misc import abstract_class
@@ -482,7 +482,7 @@
         """
         self._validate_picker_not_in_use()
         if 'use_mesh' in kwargs:
-            warnings.warn(
+            warn_external(
                 '`use_mesh` is deprecated. See `use_picker` instead.',
                 PyVistaDeprecationWarning,
             )
@@ -1052,7 +1052,7 @@
                             **_kwargs,
                         )
                 except Exception as e:  # noqa: BLE001  # pragma: no cover
-                    warnings.warn('Unable to show mesh when picking:\n\n%s', str(e))  # type: ignore[call-overload]
+                    warn_external('Unable to show mesh when picking:\n\n%s', str(e))  # type: ignore[call-overload]
 
                 # Reset to the active renderer.
                 loc = self_().renderers.index_to_loc(active_renderer_index)  # type: ignore[union-attr]
@@ -1306,7 +1306,7 @@
 
                     # if not a surface
                     if actor.GetProperty().GetRepresentation() != 2:  # pragma: no cover
-                        warnings.warn(
+                        warn_external(
                             'Display representations other than `surface` will result '
                             'in incorrect results.',
                         )
Index: python-pyvista/pyvista/plotting/plotter.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/plotter.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/plotter.py	2026-01-30 19:37:17.000543595 +0100
@@ -35,6 +35,7 @@
 
 import pyvista
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core import _validation
 from pyvista.core.errors import MissingDataError
 from pyvista.core.errors import PyVistaDeprecationWarning
@@ -146,7 +147,7 @@
         X11 = ctypes.CDLL('libX11.so')
         X11.XCloseDisplay.argtypes = [ctypes.c_void_p]
     except OSError:
-        warnings.warn('PYVISTA_KILL_DISPLAY: Unable to load X11.\nProbably using wayland')
+        warn_external('PYVISTA_KILL_DISPLAY: Unable to load X11.\nProbably using wayland')
         KILL_DISPLAY = False
 
 
@@ -203,7 +204,7 @@
         if uses_egl():
             return
 
-        warnings.warn(
+        warn_external(
             '\n'
             'This system does not appear to be running an xserver.\n'
             'PyVista will likely segfault when rendering.\n\n'
@@ -869,7 +870,7 @@
                                 continue
                             dataset = mapper.dataset
                             if not isinstance(dataset, pyvista.PolyData):
-                                warnings.warn(
+                                warn_external(
                                     'Plotter contains non-PolyData datasets. These have been '
                                     'overwritten with PolyData surfaces and are internally '
                                     'copies of the original datasets.',
@@ -879,7 +880,7 @@
                                     dataset = dataset.extract_surface()
                                     mapper.SetInputData(dataset)
                                 except (AttributeError, ValueError, TypeError):  # pragma: no cover
-                                    warnings.warn(
+                                    warn_external(
                                         'During gLTF export, failed to convert some '
                                         'datasets to PolyData. Exported scene will not have '
                                         'all datasets.',
@@ -1997,7 +1998,7 @@
             return
         # If render window is not current
         if self.render_window is None:
-            warnings.warn('Attempting to set window_size on an unavailable render widow.')
+            warn_external('Attempting to set window_size on an unavailable render widow.')
             yield self
             return
         size_before = self.window_size
@@ -3631,7 +3632,7 @@
         self.mapper = mapper
 
         if render_lines_as_tubes and show_edges:
-            warnings.warn(
+            warn_external(
                 '`show_edges=True` not supported when `render_lines_as_tubes=True`. '
                 'Ignoring `show_edges`.',
                 UserWarning,
@@ -4546,7 +4547,7 @@
                 raise ValueError(msg)
             if opacity != 'linear':
                 opacity = 'linear'
-                warnings.warn('Ignoring custom opacity due to RGBA scalars.')
+                warn_external('Ignoring custom opacity due to RGBA scalars.')
 
         # Define mapper, volume, and add the correct properties
         mappers_lookup = {
@@ -4986,7 +4987,7 @@
 
         """
         # Deprecated on 0.43.0, estimated removal on v0.46.0
-        warnings.warn(
+        warn_external(
             'This method is deprecated and will be removed in a future version of '
             'PyVista. Directly modify the scalars of a mesh in-place instead.',
             PyVistaDeprecationWarning,
@@ -6304,7 +6305,7 @@
                 if self.last_image is not None:
                     # Save last image
                     if scale is not None:
-                        warnings.warn(
+                        warn_external(
                             'This plotter is closed and cannot be scaled. '
                             'Using the last saved image. '
                             'Try using the `image_scale` property directly.',
@@ -7168,7 +7169,7 @@
         if interactive_update and auto_close is None:
             auto_close = False
         elif interactive_update and auto_close:
-            warnings.warn(
+            warn_external(
                 textwrap.dedent(
                     """
                     The plotter will close immediately automatically since ``auto_close=True``.
@@ -7203,7 +7204,7 @@
 
         # handle plotter notebook
         if jupyter_backend and not self.notebook:
-            warnings.warn(
+            warn_external(
                 'Not within a jupyter notebook environment.\nIgnoring ``jupyter_backend``.',
             )
 
@@ -7268,14 +7269,14 @@
             self._clear_ren_win()  # The ren_win is deleted
             # proper screenshots cannot be saved if this happens
             if not auto_close:
-                warnings.warn(
+                warn_external(
                     '`auto_close` ignored: by clicking the exit button, '
                     'you have destroyed the render window and we have to '
                     'close it out.',
                 )
             self.close()
             if screenshot:
-                warnings.warn(
+                warn_external(
                     'A screenshot is unable to be taken as the render window is not current or '
                     'rendering is suppressed.',
                 )
Index: python-pyvista/pyvista/plotting/plotting/__init__.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/plotting/__init__.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/plotting/__init__.py	2026-01-30 19:39:06.145070042 +0100
@@ -4,8 +4,8 @@
 
 import importlib
 import inspect
-import warnings
 
+from pyvista._warn_external import warn_external
 from pyvista.core.errors import PyVistaDeprecationWarning
 
 
@@ -28,7 +28,7 @@
         f'The `pyvista.plotting.plotting` module has been deprecated. '
         f'`{name}` is now imported as: `{import_path}`.'
     )
-    warnings.warn(
+    warn_external(
         message,
         PyVistaDeprecationWarning,
     )
Index: python-pyvista/pyvista/plotting/render_window_interactor.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/render_window_interactor.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/render_window_interactor.py	2026-01-30 19:38:03.233696576 +0100
@@ -9,13 +9,13 @@
 import logging
 import time
 from typing import Literal
-import warnings
 import weakref
 
 import numpy as np
 
 from pyvista import vtk_version_info
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core._vtk_core import DisableVtkSnakeCase
 from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.utilities.misc import _NoNewAttrMixin
@@ -573,7 +573,7 @@
         if (
             vtk_version_info < (9, 3, 0) and scene is not None and len(self._plotter.renderers) > 1
         ):  # pragma: no cover
-            warnings.warn(
+            warn_external(
                 'Interaction with charts is not possible when using multiple subplots.'
                 'Upgrade to VTK 9.3 or newer to enable this feature.',
             )
@@ -1527,7 +1527,7 @@
             The observer function to call when a pick event ends.
 
         """
-        warnings.warn(
+        warn_external(
             '`add_pick_obeserver` is deprecated, use `add_pick_observer`',
             PyVistaDeprecationWarning,
         )
Index: python-pyvista/pyvista/plotting/renderer.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/renderer.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/renderer.py	2026-01-30 19:37:53.233963322 +0100
@@ -11,7 +11,6 @@
 from typing import Any
 from typing import ClassVar
 from typing import cast
-import warnings
 
 import numpy as np
 
@@ -19,6 +18,7 @@
 from pyvista import MAX_N_COLOR_BARS
 from pyvista import vtk_version_info
 from pyvista._deprecate_positional_args import _deprecate_positional_args
+from pyvista._warn_external import warn_external
 from pyvista.core._typing_core import BoundsTuple
 from pyvista.core.errors import PyVistaDeprecationWarning
 from pyvista.core.utilities.helpers import wrap
@@ -734,7 +734,7 @@
             if uses_egl():  # pragma: no cover
                 # only display the warning when not building documentation
                 if not pyvista.BUILDING_GALLERY:
-                    warnings.warn(
+                    warn_external(
                         'VTK compiled with OSMesa/EGL does not properly support '
                         'FXAA anti-aliasing and SSAA will be used instead.',
                     )
@@ -1364,7 +1364,7 @@
         if box is None:
             box = self._theme.axes.box
         if box:
-            warnings.warn(
+            warn_external(
                 '`box` is deprecated. Use `add_box_axes` or `add_color_box_axes` method instead.',
                 PyVistaDeprecationWarning,
             )
@@ -1969,19 +1969,19 @@
 
         if 'xlabel' in kwargs:  # pragma: no cover
             xtitle = kwargs.pop('xlabel')
-            warnings.warn(
+            warn_external(
                 '`xlabel` is deprecated. Use `xtitle` instead.',
                 PyVistaDeprecationWarning,
             )
         if 'ylabel' in kwargs:  # pragma: no cover
             ytitle = kwargs.pop('ylabel')
-            warnings.warn(
+            warn_external(
                 '`ylabel` is deprecated. Use `ytitle` instead.',
                 PyVistaDeprecationWarning,
             )
         if 'zlabel' in kwargs:  # pragma: no cover
             ztitle = kwargs.pop('zlabel')
-            warnings.warn(
+            warn_external(
                 '`zlabel` is deprecated. Use `ztitle` instead.',
                 PyVistaDeprecationWarning,
             )
@@ -4176,7 +4176,7 @@
                     face_ = args.pop('face', None)
 
                     if args:
-                        warnings.warn(
+                        warn_external(
                             f'Some of the arguments given to legend are not used.\n{args}',
                         )
                 elif isinstance(args, str):
Index: python-pyvista/pyvista/plotting/texture.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/texture.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/texture.py	2026-01-30 19:38:40.624754464 +0100
@@ -4,11 +4,11 @@
 
 from collections.abc import Sequence
 from typing import TYPE_CHECKING
-import warnings
 
 import numpy as np
 
 import pyvista
+from pyvista._warn_external import warn_external
 from pyvista.core.dataobject import DataObject
 from pyvista.core.utilities.fileio import _try_imageio_imread
 from pyvista.core.utilities.misc import AnnotatedIntEnum
@@ -687,7 +687,7 @@
     """
     if image.dtype != np.uint8:
         image = image.astype(np.uint8)
-        warnings.warn(
+        warn_external(
             'Expected `image` dtype to be ``np.uint8``. `image` has been copied '
             'and converted to np.uint8.',
             UserWarning,
Index: python-pyvista/pyvista/plotting/themes.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/themes.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/themes.py	2026-01-30 19:38:51.904556300 +0100
@@ -40,9 +40,9 @@
 from pathlib import Path
 from typing import TYPE_CHECKING
 from typing import Any
-import warnings
 
 import pyvista  # noqa: TC001
+from pyvista._warn_external import warn_external
 from pyvista.core.utilities.misc import _check_range
 
 from .colors import Color
@@ -69,7 +69,7 @@
             set_plot_theme(theme.lower())
         except ValueError:
             allowed = ', '.join([item.name for item in _NATIVE_THEMES])
-            warnings.warn(
+            warn_external(
                 f'\n\nInvalid PYVISTA_PLOT_THEME environment variable "{theme}". '
                 f'Should be one of the following: {allowed}',
             )
@@ -1548,7 +1548,7 @@
     @server_proxy_enabled.setter
     def server_proxy_enabled(self, enabled: bool):
         if enabled and self.jupyter_extension_enabled:
-            warnings.warn('Enabling server_proxy will disable jupyter_extension')
+            warn_external('Enabling server_proxy will disable jupyter_extension')
             self._jupyter_extension_enabled = False
 
         self._server_proxy_enabled = bool(enabled)
@@ -1569,7 +1569,7 @@
 
     @jupyter_extension_available.setter
     def jupyter_extension_available(self, _available: bool):
-        warnings.warn(
+        warn_external(
             'The jupyter_extension_available flag is read only and is automatically detected.',
         )
 
@@ -1585,7 +1585,7 @@
             raise ValueError(msg)
 
         if enabled and self.server_proxy_enabled:
-            warnings.warn('Enabling jupyter_extension will disable server_proxy')
+            warn_external('Enabling jupyter_extension will disable server_proxy')
             self._server_proxy_enabled = False
 
         self._jupyter_extension_enabled = bool(enabled)
Index: python-pyvista/pyvista/plotting/utilities/xvfb.py
===================================================================
--- python-pyvista.orig/pyvista/plotting/utilities/xvfb.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/plotting/utilities/xvfb.py	2026-01-30 19:39:17.121447550 +0100
@@ -4,8 +4,8 @@
 
 import os
 import time
-import warnings
 
+from pyvista._warn_external import warn_external
 from pyvista.core.errors import PyVistaDeprecationWarning
 
 XVFB_INSTALL_NOTES = """Please install Xvfb with:
@@ -45,7 +45,7 @@
 
     """
     # Deprecated on 0.45.0, estimated removal on 0.48.0
-    warnings.warn(
+    warn_external(
         'This function is deprecated and will be removed in future version of '
         'PyVista. Use vtk-osmesa instead.',
         PyVistaDeprecationWarning,
Index: python-pyvista/pyvista/trame/jupyter.py
===================================================================
--- python-pyvista.orig/pyvista/trame/jupyter.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/trame/jupyter.py	2026-01-30 19:39:53.434839931 +0100
@@ -7,13 +7,13 @@
 import os
 from typing import TYPE_CHECKING
 from typing import Literal
-import warnings
 
 from trame.widgets import html as html_widgets
 from trame.widgets import vtk as vtk_widgets
 from trame.widgets import vuetify as vuetify2_widgets
 from trame.widgets import vuetify3 as vuetify3_widgets
 from typing_extensions import Concatenate
+from pyvista._warn_external import warn_external
 
 try:
     from ipywidgets.widgets import HTML
@@ -77,7 +77,7 @@
         """Call the base class constructor with the custom message."""
         # Be incredibly verbose on how users should launch trame server
         # Both warn so it appears at top
-        warnings.warn(JUPYTER_SERVER_DOWN_MESSAGE)
+        warn_external(JUPYTER_SERVER_DOWN_MESSAGE)
         # and Error
         super().__init__(JUPYTER_SERVER_DOWN_MESSAGE)
 
Index: python-pyvista/pyvista/trame/ui/__init__.py
===================================================================
--- python-pyvista.orig/pyvista/trame/ui/__init__.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/trame/ui/__init__.py	2026-01-30 19:40:04.434918792 +0100
@@ -9,10 +9,11 @@
 from __future__ import annotations
 
 from typing import TYPE_CHECKING
-import warnings
 
 from trame.app import get_server
 
+from pyvista._warn_external import warn_external
+
 from .vuetify2 import Viewer as Vue2Viewer
 from .vuetify3 import Viewer as Vue3Viewer
 
@@ -50,7 +51,7 @@
         viewer = _VIEWERS[plotter._id_name]
         if suppress_rendering != plotter.suppress_rendering:
             plotter.suppress_rendering = suppress_rendering
-            warnings.warn(
+            warn_external(
                 'Suppress rendering on the plotter is changed to ' + str(suppress_rendering),
                 UserWarning,
             )
Index: python-pyvista/pyvista/utilities/__init__.py
===================================================================
--- python-pyvista.orig/pyvista/utilities/__init__.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/pyvista/utilities/__init__.py	2026-01-30 19:40:19.145357588 +0100
@@ -4,7 +4,8 @@
 
 import importlib
 import inspect
-import warnings
+
+from pyvista._warn_external import warn_external
 
 # Places to look for the utility
 _MODULES = [
@@ -74,7 +75,7 @@
 
     from pyvista.core.errors import PyVistaDeprecationWarning  # noqa: PLC0415
 
-    warnings.warn(
+    warn_external(
         message,
         PyVistaDeprecationWarning,
     )
Index: python-pyvista/tests/plotting/test_utilities.py
===================================================================
--- python-pyvista.orig/tests/plotting/test_utilities.py	2026-01-30 19:21:37.486605165 +0100
+++ python-pyvista/tests/plotting/test_utilities.py	2026-01-30 19:21:37.482678966 +0100
@@ -32,6 +32,7 @@
         _test_start_xvfb()
 
 
+@pytest.mark.skip_windows('monkeypatching os.name conflicts with pathlib')
 def test_start_xvfb_raises(monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture):
     monkeypatch.setattr(os, 'name', 'foo')
     with (
Index: python-pyvista/tests/test_hooks.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ python-pyvista/tests/test_hooks.py	2026-01-30 19:21:37.482733011 +0100
@@ -0,0 +1,80 @@
+"""Test the pre-commit hooks"""
+
+from __future__ import annotations
+
+import shlex
+import subprocess
+import sys
+import textwrap
+from typing import TYPE_CHECKING
+
+import pytest
+import yaml
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+
+@pytest.fixture(scope='session')
+def pre_commit_config(request: pytest.FixtureRequest):
+    with (request.config.rootpath / '.pre-commit-config.yaml').open() as f:
+        return yaml.safe_load(f)
+
+
+def test_warnings_converter(
+    tmp_path: Path,
+    pre_commit_config: dict,
+    request: pytest.FixtureRequest,
+):
+    test = """\
+    import warnings
+
+    warnings.warn("foo")
+    warnings.warn("foo", UserWarning)
+    warnings.warn("foo", UserWarning, 1)
+    warnings.warn("foo", UserWarning, stacklevel=1)
+    warnings.warn("foo", category=UserWarning, stacklevel=1)
+    warnings.warn(message="foo", category=UserWarning, stacklevel=1)
+    warnings.warn(category=UserWarning, stacklevel=1, message="foo")
+    """
+
+    if sys.version_info[:2] >= (3, 12):
+        test += """
+    warnings.warn(category=UserWarning, stacklevel=1, message="foo", source='bar', skip_file_prefixes=('',))
+    """  # noqa: E501
+
+    with (file := (tmp_path / 'file.py')).open('w') as f:
+        f.write(textwrap.dedent(test))
+
+    local = next(v for v in pre_commit_config['repos'] if v['repo'] == 'local')
+    warning_hook = next(v for v in local['hooks'] if v['id'] == 'warn_external')
+    cml = warning_hook['entry']
+
+    ret = subprocess.run(
+        [sys.executable, *shlex.split(cml)[1:], str(file.absolute())],
+        check=True,
+        cwd=request.config.rootpath,
+    )
+    assert ret.returncode == 0
+
+    with file.open('r') as f:
+        lines = f.readlines()
+
+    expected = """\
+        from pyvista._warn_external import warn_external
+
+        warn_external("foo")
+        warn_external("foo", UserWarning)
+        warn_external("foo", UserWarning)
+        warn_external("foo", UserWarning)
+        warn_external("foo", category=UserWarning)
+        warn_external(message="foo", category=UserWarning)
+        warn_external(message="foo", category=UserWarning)
+        """
+
+    if sys.version_info[:2] >= (3, 12):
+        expected += """
+        warn_external(message="foo", category=UserWarning)
+        """
+
+    assert textwrap.dedent(expected) == ''.join(lines)
Index: python-pyvista/tests/test_warn_external.py
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ python-pyvista/tests/test_warn_external.py	2026-01-30 19:21:37.482869505 +0100
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import pathlib
+import sys
+from typing import TYPE_CHECKING
+
+import pytest
+
+from pyvista._warn_external import warn_external
+
+if TYPE_CHECKING:
+    from pytest_mock import MockerFixture
+
+
+def test_warn_external(recwarn: pytest.WarningsRecorder):
+    """Taken and adapted from
+    https://github.com/matplotlib/matplotlib/blob/a00d606d592bcf8d335f4f3ac2768882d3a49e7b/lib/matplotlib/tests/test_cbook.py#L509"""
+
+    warn_external('oops')
+    assert len(recwarn) == 1
+    if sys.version_info[:2] >= (3, 12):
+        # With Python 3.12, we let Python figure out the stacklevel using the
+        # `skip_file_prefixes` argument, which cannot exempt tests, so just confirm
+        # the filename is not in the package.
+        basedir = pathlib.Path(__file__).parents[1]
+        assert not recwarn[0].filename.startswith(str(basedir / 'pyvista'))
+
+    else:
+        # On older Python versions, we manually calculated the stacklevel, and had an
+        # exception for our own tests.
+        assert recwarn[0].filename == __file__
+
+
+def test_warn_external_frame_embedded_python(mocker: MockerFixture):
+    """Taken and adapted from
+    https://github.com/matplotlib/matplotlib/blob/a00d606d592bcf8d335f4f3ac2768882d3a49e7b/lib/matplotlib/tests/test_cbook.py#L525"""
+    m = mocker.patch.object(sys, '_getframe')
+    m.return_value = None
+    with pytest.warns(UserWarning, match=r'\Adummy\Z'):
+        warn_external('dummy')
