Skip to content

[FLINK-40529][python] Warn on use of deprecated APIs, not at import time - #29061

Draft
deepyaman wants to merge 3 commits into
apache:masterfrom
deepyaman:FLINK-40529
Draft

[FLINK-40529][python] Warn on use of deprecated APIs, not at import time#29061
deepyaman wants to merge 3 commits into
apache:masterfrom
deepyaman:FLINK-40529

Conversation

@deepyaman

Copy link
Copy Markdown
Contributor

What is the purpose of the change

Deprecated in flink-python/pyflink/util/api_stability_decorators.py emitted its DeprecationWarning from __call__, which the decorator syntax invokes in order to apply the decorator. The warning therefore fired at decoration time — that is, at import — and the decorated function/class was returned unwrapped, so:

  • importing pyflink.table emitted a DeprecationWarning for every deprecated API it defines, whether or not the user touches them;
  • actually calling a deprecated API emitted nothing;
  • stacklevel=2 pointed at the decoration site inside PyFlink's own source, not at user code.
$ cd flink-python && python -W error::DeprecationWarning -c "import pyflink.table"
  File ".../pyflink/table/table_schema.py", line 28, in <module>
    @Deprecated(since="2.1.0", detail="""
  File ".../pyflink/util/api_stability_decorators.py", line 141, in __call__
    warnings.warn(msg, category=DeprecationWarning, stacklevel=2)
DeprecationWarning: TableSchema has been deprecated since version 2.1.0. ...

FLINK-37365, which introduced these decorators, describes the intended behaviour as warning "at runtime on their invocation", so this was an oversight. PyFlink supports Python >= 3.9, so warnings.deprecated (PEP 702) is not available; the fix is by hand, mirroring PEP 702's semantics where reasonable.

Brief change log

  • Deprecated applied to a function returns a functools.wraps wrapper that warns when the function is called, with stacklevel=2 so the warning is attributed to the caller.
  • Deprecated applied to a class returns the class itself and wraps __init__ on it, so isinstance checks and subclassing are unaffected. As in PEP 702 only instantiating the deprecated class itself warns, which also avoids warning twice when a deprecated class inherits the __init__ of a deprecated base class.
  • staticmethod/classmethod objects are unwrapped, decorated and re-packaged; properties, ABCs and Enum subclasses fall back to applying the docstring directive alone rather than raising. Decorating any of these previously failed, as did omitting the detail argument.
  • The message format, the DeprecationWarning category, the docstring/Sphinx directives and the __stability_decorators attribute read by PythonAPICompletenessTestCase are unchanged; Experimental, Internal, Public and PublicEvolving are untouched.
  • pyflink/util was not among the modules dev/integration_test.sh runs, so it is added there.

Verifying this change

This change added tests and can be verified as follows:

  • Added pyflink/util/tests/test_api_stability_decorators.py: no warning at decoration; a regression test that imports pyflink.table in a fresh interpreter and asserts no deprecation warning is emitted; warning on function call and on class instantiation; the warning is attributed to the caller's file and line; docstring directives still applied; __stability_decorators still populated; staticmethod/classmethod/property/ABC/Enum cases; subclassing and double-warning guards; the other four decorators still silent and still returning their argument unchanged.
  • Red-green verified: 18 of the 21 new tests fail against the unfixed decorator, and all 21 pass with the fix, on Python 3.9 (the minimum supported) and 3.11.
  • Manually verified that python -W error::DeprecationWarning -c "import pyflink.table" no longer raises, and that calling Table.get_schema warns exactly once, pointing at the calling script.
  • flake8 and mypy as configured in flink-python/tox.ini are clean, and the Sphinx docs build (SPHINXOPTS="-a -W" make html) still succeeds, with Deprecated since version 2.1.0 still rendered on the affected APIs.

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it add or upgrade a dependency): no
  • The public API, i.e., is any changed class annotated with @Public(Evolving): no
  • The serializers: no
  • The runtime per-record code paths (performance sensitive): no
  • Anything that affects deployment or recovery: JobManager (and its components), Checkpointing, Kubernetes/Yarn, ZooKeeper: no
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? no
  • If yes, how is the feature documented? not applicable

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

Generated-by: Claude Code 2.1.252 (Claude Opus 5)

Deprecated emitted its DeprecationWarning from __call__, which the
decorator syntax invokes in order to apply the decorator. The warning
therefore fired at decoration time -- that is, at import -- for every
deprecated API a module defines, whether or not the user touches it,
while actually calling one emitted nothing, and stacklevel=2 pointed
at the decoration site inside PyFlink's own source rather than at user
code.

Functions now get a functools.wraps wrapper that warns when called.
Classes are returned unchanged, with __init__ wrapped in place so that
isinstance checks and subclassing keep working; as in PEP 702, only
instantiating the deprecated class itself warns, which also avoids
warning twice when a deprecated class inherits the __init__ of a
deprecated base class. staticmethod and classmethod objects are
unwrapped and re-packaged, and properties, ABCs and Enum subclasses
degrade to the docstring directive rather than raising -- decorating
any of these used to fail. A missing detail argument no longer raises
either.

The message format, the DeprecationWarning category, the docstring
directives and the __stability_decorators attribute read by
PythonAPICompletenessTestCase are unchanged, and Experimental,
Internal, Public and PublicEvolving are unaffected.

pyflink/util was not in the list of modules that dev/integration_test.sh
runs, so it is added there for the new tests to run in CI.

Generated-by: Claude Code 2.1.252 (Claude Opus 5)
@flinkbot

flinkbot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands The @flinkbot bot supports the following commands:
  • @flinkbot run azure re-run the last Azure build

Comment on lines +36 to +44
def _catch_warnings():
"""
Returns a context manager recording every warning raised within it.
"""
context = warnings.catch_warnings(record=True)
warnings.simplefilter("always")
return context


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use something like assertWarns (or pytest.raises, although PyFlink doesn't seem to generally use pytest for whatever reason).

Comment on lines +67 to +68
# pyflink.table used to warn as soon as the package was imported. This needs a fresh
# interpreter, as pyflink.table is already imported in the one running the tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really the best approach? How is this usually done?

Comment on lines +70 to +75
"import warnings\n"
"with warnings.catch_warnings(record=True) as caught:\n"
" warnings.simplefilter('always')\n"
" import pyflink.table\n"
"print([str(warning.message) for warning in caught\n"
" if 'has been deprecated since version' in str(warning.message)])\n"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to use a triple-quoted string instead?

Comment on lines +80 to +83
try:
func_or_cls.__doc__ = f"{docstring}\n{directive}"
except (AttributeError, TypeError):
pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of the try/except here?

Comment on lines +91 to +97
# Not every decorated object accepts attribute assignment (a property, for
# example). Those simply cannot be introspected; that is not a reason to fail
# at import time.
try:
setattr(func_or_cls, '__stability_decorators', {self.__class__})
except (AttributeError, TypeError):
pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, is this actually being exercised? Or is this unnecessarily-defensive coding?

return msg

@override
def __call__(self, func_or_cls: T) -> T:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the changes (and also the existing code) is very complicated! Any reason this can't use @deprecated from typing-extensions>=4.5.0, at least as a base?

If not, could a lot not at least be learned from the PEP-702 implementation?

Review feedback on the previous commit.

A deprecated function is now wrapped by typing_extensions.deprecated,
the backport of PEP 702, rather than by a hand-written wrapper: it
warns with the caller's stacklevel and sets the __deprecated__
attribute that type checkers read.

Classes keep their own wrapper around __init__. PEP 702 warns when a
deprecated class is subclassed as well as when it is instantiated, and
Rowtime and Schema extend the deprecated Descriptor at module level, so
adopting it for classes would warn when pyflink.table.descriptors is
imported -- the behaviour this ticket is fixing. For the same reason a
staticmethod and a classmethod are still unwrapped and re-packaged:
typing_extensions.deprecated rejects a classmethod, and on Python 3.10
and later it turns a staticmethod into a plain function that then fails
when called on an instance. Both cases now have regression tests.

typing_extensions has been imported by this module since FLINK-37365
without being declared as a dependency; it resolved only because
apache-beam happens to require it. It is now declared, with the 4.5.0
floor that typing_extensions.deprecated needs.

The guards around the docstring assignment and around replacing
__init__ are gone: no decorated object reaches them. The guard around
recording __stability_decorators stays, because a property does reach
it and rejects attribute assignment.

The test helper installed its warning filter before entering
catch_warnings, which leaked "always" into the global filter state for
the rest of the session; it is a context manager now. Assertions that
something warns use assertWarns, and the helper is only used where it
cannot: that nothing warned, or that something warned exactly once.

Generated-by: Claude Code 2.1.252 (Claude Opus 5)
@github-actions github-actions Bot added the community-reviewed PR has been reviewed by the community. label Sep 3, 2026
Comment on lines +38 to +48
@contextlib.contextmanager
def _catch_warnings():
"""
Returns a context manager recording every warning raised within it.
Records every warning raised within the block.

Used where :func:`unittest.TestCase.assertWarns` cannot express the assertion: that
nothing warned, or that something warned exactly once.
"""
context = warnings.catch_warnings(record=True)
warnings.simplefilter("always")
return context
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
yield caught

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still, instead of doing this, why can't we just us pytest.warns? Would there be some incompatibility with unittest? I assume not...

@@ -131,33 +150,41 @@ class Cls(object):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's with all these Cls inheriting from object? Isn't that more of a Python 2 construct?

Comment on lines 166 to +176
@@ -174,57 +170,50 @@ def __call__(self, func_or_cls: T) -> T:
if isclass(func_or_cls):
self._deprecate_class(func_or_cls)
elif isfunction(func_or_cls):
return cast(T, self._deprecate_function(func_or_cls))
# Anything else (a property, for instance) cannot be wrapped without changing what the
# decorated name refers to, so the docstring directive is all we apply.
# PEP 702's implementation, by way of its typing_extensions backport: a
# functools.wraps wrapper that warns with the caller's stacklevel, plus the
# __deprecated__ attribute that type checkers read.
return cast(T, deprecated(self._get_message(func_or_cls))(func_or_cls))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these casts wholly necessary? If it's necessary for type checking, I get it, but just want to be sure.

Comment on lines +189 to +190
and PyFlink subclasses its own deprecated classes -- Rowtime and Schema in
pyflink.table.descriptors both extend the deprecated Descriptor -- so that warning

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this level of detail re specific examples necessary for a docstring?

Review feedback on the previous commits. No behaviour change.

Comments and docstrings that restated the code, or carried detail that
belongs in the commit message, are cut back to the reasoning a reader
needs: why the warning cannot be emitted at decoration time, why
typing_extensions.deprecated covers functions but not classes, and why
a property is only documented. The class names that illustrated the
subclassing problem are gone from the docstring, since they would rot
as pyflink.table.descriptors changes.

The test fixtures no longer inherit from object explicitly.

Generated-by: Claude Code 2.1.252 (Claude Opus 5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-reviewed PR has been reviewed by the community.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants