diff --git a/sklearn/base.py b/sklearn/base.py
index d646f8d3e56bf..2c82cf05a6c5a 100644
--- a/sklearn/base.py
+++ b/sklearn/base.py
@@ -389,6 +389,33 @@ def __setstate__(self, state):
         except AttributeError:
             self.__dict__.update(state)

+    # TODO(1.7): Remove this method
+    def _more_tags(self):
+        """This code should never be reached since our `get_tags` will fallback on
+        `__sklearn_tags__` implemented below. We keep it for backward compatibility.
+        It is tested in `test_base_estimator_more_tags` in
+        `sklearn/utils/testing/test_tags.py`."""
+        from sklearn.utils._tags import _to_old_tags, default_tags
+
+        warnings.warn(
+            "The `_more_tags` method is deprecated in 1.6 and will be removed in "
+            "1.7. Please implement the `__sklearn_tags__` method.",
+            category=FutureWarning,
+        )
+        return _to_old_tags(default_tags(self))
+
+    # TODO(1.7): Remove this method
+    def _get_tags(self):
+        from sklearn.utils._tags import _to_old_tags, get_tags
+
+        warnings.warn(
+            "The `_get_tags` method is deprecated in 1.6 and will be removed in "
+            "1.7. Please implement the `__sklearn_tags__` method.",
+            category=FutureWarning,
+        )
+
+        return _to_old_tags(get_tags(self))
+
     def __sklearn_tags__(self):
         return Tags(
             estimator_type=None,
diff --git a/sklearn/utils/_tags.py b/sklearn/utils/_tags.py
index ccbc9d2438268..1ba1913c37234 100644
--- a/sklearn/utils/_tags.py
+++ b/sklearn/utils/_tags.py
@@ -1,7 +1,9 @@
 from __future__ import annotations

 import warnings
+from collections import OrderedDict
 from dataclasses import dataclass, field
+from itertools import chain

 from .fixes import _dataclass_args

@@ -290,6 +292,71 @@ def default_tags(estimator) -> Tags:
     )

+# TODO(1.7): Remove this function
+def _find_tags_provider(estimator, warn=True):
+    """Find the tags provider for an estimator.
+
+    Parameters
+    ----------
+    estimator : estimator object
+        The estimator to find the tags provider for.
+
+    warn : bool, default=True
+        Whether to warn if the tags provider is not found.
+
+    Returns
+    -------
+    tag_provider : str
+        The tags provider for the estimator. Can be one of:
+        - "_get_tags": to use the old tags infrastructure
+        - "__sklearn_tags__": to use the new tags infrastructure
+    """
+    mro_model = type(estimator).mro()
+    tags_mro = OrderedDict()
+    for klass in mro_model:
+        tags_provider = []
+        if "_more_tags" in vars(klass):
+            tags_provider.append("_more_tags")
+        if "_get_tags" in vars(klass):
+            tags_provider.append("_get_tags")
+        if "__sklearn_tags__" in vars(klass):
+            tags_provider.append("__sklearn_tags__")
+        tags_mro[klass.__name__] = tags_provider
+
+    all_providers = set(chain.from_iterable(tags_mro.values()))
+    if "__sklearn_tags__" not in all_providers:
+        # default on the old tags infrastructure
+        return "_get_tags"
+
+    tag_provider = "__sklearn_tags__"
+    for klass in tags_mro:
+        has_get_or_more_tags = any(
+            provider in tags_mro[klass] for provider in ("_get_tags", "_more_tags")
+        )
+        has_sklearn_tags = "__sklearn_tags__" in tags_mro[klass]
+
+        if tags_mro[klass] and tag_provider == "__sklearn_tags__":  # is it empty
+            if has_get_or_more_tags and not has_sklearn_tags:
+                # Case where a class does not implement __sklearn_tags__ and we fallback
+                # to _get_tags. We should therefore warn for implementing
+                # __sklearn_tags__.
+                tag_provider = "_get_tags"
+                break
+
+    if warn and tag_provider == "_get_tags":
+        warnings.warn(
+            f"The {estimator.__class__.__name__} or classes from which it inherits "
+            "use `_get_tags` and `_more_tags`. Please define the "
+            "`__sklearn_tags__` method, or inherit from `sklearn.base.BaseEstimator` "
+            "and/or other appropriate mixins such as `sklearn.base.TransformerMixin`, "
+            "`sklearn.base.ClassifierMixin`, `sklearn.base.RegressorMixin`, and "
+            "`sklearn.base.OutlierMixin`. From scikit-learn 1.7, not defining "
+            "`__sklearn_tags__` will raise an error.",
+            category=FutureWarning,
+        )
+    return tag_provider
+
+
 def get_tags(estimator) -> Tags:
     """Get estimator tags.

@@ -316,19 +383,201 @@ def get_tags(estimator) -> Tags:
         The estimator tags.
     """

-    if hasattr(estimator, "__sklearn_tags__"):
+    tag_provider = _find_tags_provider(estimator)
+
+    if tag_provider == "__sklearn_tags__":
         tags = estimator.__sklearn_tags__()
     else:
-        warnings.warn(
-            f"Estimator {estimator} has no __sklearn_tags__ attribute, which is "
-            "defined in `sklearn.base.BaseEstimator`. This will raise an error in "
-            "scikit-learn 1.8. Please define the __sklearn_tags__ method, or inherit "
-            "from `sklearn.base.BaseEstimator` and other appropriate mixins such as "
-            "`sklearn.base.TransformerMixin`, `sklearn.base.ClassifierMixin`, "
-            "`sklearn.base.RegressorMixin`, and `sklearn.base.ClusterMixin`, and "
-            "`sklearn.base.OutlierMixin`.",
-            category=FutureWarning,
+        # TODO(1.7): Remove this branch of the code
+        # Let's go through the MRO and patch each class implementing _more_tags
+        sklearn_tags_provider = {}
+        more_tags_provider = {}
+        class_order = []
+        for klass in reversed(type(estimator).mro()):
+            if "__sklearn_tags__" in vars(klass):
+                sklearn_tags_provider[klass] = klass.__sklearn_tags__(estimator)  # type: ignore[attr-defined]
+                class_order.append(klass)
+            elif "_more_tags" in vars(klass):
+                more_tags_provider[klass] = klass._more_tags(estimator)  # type: ignore[attr-defined]
+                class_order.append(klass)
+
+        # Find differences between consecutive in the case of __sklearn_tags__
+        # inheritance
+        sklearn_tags_diff = {}
+        items = list(sklearn_tags_provider.items())
+        for current_item, next_item in zip(items[:-1], items[1:]):
+            current_name, current_tags = current_item
+            next_name, next_tags = next_item
+            current_tags = _to_old_tags(current_tags)
+            next_tags = _to_old_tags(next_tags)
+
+            # Compare tags and store differences
+            diff = {}
+            for key in current_tags:
+                if current_tags[key] != next_tags[key]:
+                    diff[key] = next_tags[key]
+
+            sklearn_tags_diff[next_name] = diff
+
+        tags = {}
+        for klass in class_order:
+            if klass in sklearn_tags_diff:
+                tags.update(sklearn_tags_diff[klass])
+            elif klass in more_tags_provider:
+                tags.update(more_tags_provider[klass])
+
+        tags = _to_new_tags(
+            {**_to_old_tags(default_tags(estimator)), **tags}, estimator
         )
-        tags = default_tags(estimator)

     return tags
+
+
+# TODO(1.7): Remove this function
+def _safe_tags(estimator, key=None):
+    warnings.warn(
+        "The `_safe_tags` function is deprecated in 1.6 and will be removed in "
+        "1.7. Use the public `get_tags` function instead and make sure to implement "
+        "the `__sklearn_tags__` method.",
+        category=FutureWarning,
+    )
+    tags = _to_old_tags(get_tags(estimator))
+
+    if key is not None:
+        if key not in tags:
+            raise ValueError(
+                f"The key {key} is not defined for the class "
+                f"{estimator.__class__.__name__}."
+            )
+        return tags[key]
+    return tags
+
+
+# TODO(1.7): Remove this function
+def _to_new_tags(old_tags, estimator=None):
+    """Utility function convert old tags (dictionary) to new tags (dataclass)."""
+    input_tags = InputTags(
+        one_d_array="1darray" in old_tags["X_types"],
+        two_d_array="2darray" in old_tags["X_types"],
+        three_d_array="3darray" in old_tags["X_types"],
+        sparse="sparse" in old_tags["X_types"],
+        categorical="categorical" in old_tags["X_types"],
+        string="string" in old_tags["X_types"],
+        dict="dict" in old_tags["X_types"],
+        positive_only=old_tags["requires_positive_X"],
+        allow_nan=old_tags["allow_nan"],
+        pairwise=old_tags["pairwise"],
+    )
+    target_tags = TargetTags(
+        required=old_tags["requires_y"],
+        one_d_labels="1dlabels" in old_tags["X_types"],
+        two_d_labels="2dlabels" in old_tags["X_types"],
+        positive_only=old_tags["requires_positive_y"],
+        multi_output=old_tags["multioutput"] or old_tags["multioutput_only"],
+        single_output=not old_tags["multioutput_only"],
+    )
+    if estimator is not None and (
+        hasattr(estimator, "transform") or hasattr(estimator, "fit_transform")
+    ):
+        transformer_tags = TransformerTags(
+            preserves_dtype=old_tags["preserves_dtype"],
+        )
+    else:
+        transformer_tags = None
+    estimator_type = getattr(estimator, "_estimator_type", None)
+    if estimator_type == "classifier":
+        classifier_tags = ClassifierTags(
+            poor_score=old_tags["poor_score"],
+            multi_class=not old_tags["binary_only"],
+            multi_label=old_tags["multilabel"],
+        )
+    else:
+        classifier_tags = None
+    if estimator_type == "regressor":
+        regressor_tags = RegressorTags(
+            poor_score=old_tags["poor_score"],
+            multi_label=old_tags["multilabel"],
+        )
+    else:
+        regressor_tags = None
+    return Tags(
+        estimator_type=estimator_type,
+        target_tags=target_tags,
+        transformer_tags=transformer_tags,
+        classifier_tags=classifier_tags,
+        regressor_tags=regressor_tags,
+        input_tags=input_tags,
+        array_api_support=old_tags["array_api_support"],
+        no_validation=old_tags["no_validation"],
+        non_deterministic=old_tags["non_deterministic"],
+        requires_fit=old_tags["requires_fit"],
+        _skip_test=old_tags["_skip_test"],
+    )
+
+
+# TODO(1.7): Remove this function
+def _to_old_tags(new_tags):
+    """Utility function convert old tags (dictionary) to new tags (dataclass)."""
+    if new_tags.classifier_tags:
+        binary_only = not new_tags.classifier_tags.multi_class
+        multilabel_clf = new_tags.classifier_tags.multi_label
+        poor_score_clf = new_tags.classifier_tags.poor_score
+    else:
+        binary_only = False
+        multilabel_clf = False
+        poor_score_clf = False
+
+    if new_tags.regressor_tags:
+        multilabel_reg = new_tags.regressor_tags.multi_label
+        poor_score_reg = new_tags.regressor_tags.poor_score
+    else:
+        multilabel_reg = False
+        poor_score_reg = False
+
+    if new_tags.transformer_tags:
+        preserves_dtype = new_tags.transformer_tags.preserves_dtype
+    else:
+        preserves_dtype = ["float64"]
+
+    tags = {
+        "allow_nan": new_tags.input_tags.allow_nan,
+        "array_api_support": new_tags.array_api_support,
+        "binary_only": binary_only,
+        "multilabel": multilabel_clf or multilabel_reg,
+        "multioutput": new_tags.target_tags.multi_output,
+        "multioutput_only": (
+            not new_tags.target_tags.single_output and new_tags.target_tags.multi_output
+        ),
+        "no_validation": new_tags.no_validation,
+        "non_deterministic": new_tags.non_deterministic,
+        "pairwise": new_tags.input_tags.pairwise,
+        "preserves_dtype": preserves_dtype,
+        "poor_score": poor_score_clf or poor_score_reg,
+        "requires_fit": new_tags.requires_fit,
+        "requires_positive_X": new_tags.input_tags.positive_only,
+        "requires_y": new_tags.target_tags.required,
+        "requires_positive_y": new_tags.target_tags.positive_only,
+        "_skip_test": new_tags._skip_test,
+        "stateless": new_tags.requires_fit,
+    }
+    X_types = []
+    if new_tags.input_tags.one_d_array:
+        X_types.append("1darray")
+    if new_tags.input_tags.two_d_array:
+        X_types.append("2darray")
+    if new_tags.input_tags.three_d_array:
+        X_types.append("3darray")
+    if new_tags.input_tags.sparse:
+        X_types.append("sparse")
+    if new_tags.input_tags.categorical:
+        X_types.append("categorical")
+    if new_tags.input_tags.string:
+        X_types.append("string")
+    if new_tags.input_tags.dict:
+        X_types.append("dict")
+    if new_tags.target_tags.one_d_labels:
+        X_types.append("1dlabels")
+    if new_tags.target_tags.two_d_labels:
+        X_types.append("2dlabels")
+    tags["X_types"] = X_types
+    return tags

diff --git a/sklearn/utils/tests/test_tags.py b/sklearn/utils/tests/test_tags.py
index 413fbc6bbd3de..86e4e2d7c431e 100644
--- a/sklearn/utils/tests/test_tags.py
+++ b/sklearn/utils/tests/test_tags.py
@@ -7,7 +7,16 @@
     RegressorMixin,
     TransformerMixin,
 )
-from sklearn.utils import Tags, get_tags
+from sklearn.utils import (
+    ClassifierTags,
+    InputTags,
+    RegressorTags,
+    Tags,
+    TargetTags,
+    TransformerTags,
+    get_tags,
+)
+from sklearn.utils._tags import _safe_tags, _to_new_tags, _to_old_tags, default_tags
 from sklearn.utils.estimator_checks import (
     check_estimator_tags_renamed,
     check_valid_tag_types,
@@ -78,3 +87,546 @@ def __sklearn_tags__(self):
             return tags

     check_valid_tag_types("MyEstimator", MyEstimator())
+
+
+########################################################################################
+# Test for the deprecation
+# TODO(1.7): Remove this
+########################################################################################
+
+
+class MixinAllowNanOldTags:
+    def _more_tags(self):
+        return {"allow_nan": True}
+
+
+class MixinAllowNanNewTags:
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.allow_nan = True
+        return tags
+
+
+class MixinAllowNanOldNewTags:
+    def _more_tags(self):
+        return {"allow_nan": True}  # pragma: no cover
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.input_tags.allow_nan = True
+        return tags
+
+
+class MixinArrayApiSupportOldTags:
+    def _more_tags(self):
+        return {"array_api_support": True}
+
+
+class MixinArrayApiSupportNewTags:
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.array_api_support = True
+        return tags
+
+
+class MixinArrayApiSupportOldNewTags:
+    def _more_tags(self):
+        return {"array_api_support": True}  # pragma: no cover
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.array_api_support = True
+        return tags
+
+
+class PredictorOldTags(BaseEstimator):
+    def _more_tags(self):
+        return {"requires_fit": True}
+
+
+class PredictorNewTags(BaseEstimator):
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.requires_fit = True
+        return tags
+
+
+class PredictorOldNewTags(BaseEstimator):
+    def _more_tags(self):
+        return {"requires_fit": True}  # pragma: no cover
+
+    def __sklearn_tags__(self):
+        tags = super().__sklearn_tags__()
+        tags.requires_fit = True
+        return tags
+
+
+def test_get_tags_backward_compatibility():
+    warn_msg = "Please define the `__sklearn_tags__` method"
+
+    ####################################################################################
+    # only predictor inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    for predictor_cls in predictor_classes:
+        if predictor_cls.__name__.endswith("OldTags"):
+            with pytest.warns(FutureWarning, match=warn_msg):
+                tags = get_tags(predictor_cls())
+        else:
+            tags = get_tags(predictor_cls())
+        assert tags.requires_fit
+
+    ####################################################################################
+    # one mixin and one predictor all inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    allow_nan_classes = [
+        MixinAllowNanNewTags,
+        MixinAllowNanOldNewTags,
+        MixinAllowNanOldTags,
+    ]
+
+    for allow_nan_cls in allow_nan_classes:
+        for predictor_cls in predictor_classes:
+
+            class ChildClass(allow_nan_cls, predictor_cls):
+                pass
+
+            if any(
+                base_cls.__name__.endswith("OldTags")
+                for base_cls in (predictor_cls, allow_nan_cls)
+            ):
+                with pytest.warns(FutureWarning, match=warn_msg):
+                    tags = get_tags(ChildClass())
+            else:
+                tags = get_tags(ChildClass())
+
+            assert tags.input_tags.allow_nan
+            assert tags.requires_fit
+
+    ####################################################################################
+    # two mixins and one predictor all inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    array_api_classes = [
+        MixinArrayApiSupportNewTags,
+        MixinArrayApiSupportOldNewTags,
+        MixinArrayApiSupportOldTags,
+    ]
+    allow_nan_classes = [
+        MixinAllowNanNewTags,
+        MixinAllowNanOldNewTags,
+        MixinAllowNanOldTags,
+    ]
+
+    for predictor_cls in predictor_classes:
+        for array_api_cls in array_api_classes:
+            for allow_nan_cls in allow_nan_classes:
+
+                class ChildClass(allow_nan_cls, array_api_cls, predictor_cls):
+                    pass
+
+                if any(
+                    base_cls.__name__.endswith("OldTags")
+                    for base_cls in (predictor_cls, array_api_cls, allow_nan_cls)
+                ):
+                    with pytest.warns(FutureWarning, match=warn_msg):
+                        tags = get_tags(ChildClass())
+                else:
+                    tags = get_tags(ChildClass())
+
+                assert tags.input_tags.allow_nan
+                assert tags.array_api_support
+                assert tags.requires_fit
+
+
+@pytest.mark.filterwarnings(
+    "ignore:.*Please define the `__sklearn_tags__` method.*:FutureWarning"
+)
+def test_safe_tags_backward_compatibility():
+    warn_msg = "The `_safe_tags` function is deprecated in 1.6"
+
+    ####################################################################################
+    # only predictor inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    for predictor_cls in predictor_classes:
+        with pytest.warns(FutureWarning, match=warn_msg):
+            tags = _safe_tags(predictor_cls())
+        assert tags["requires_fit"]
+
+    ####################################################################################
+    # one mixin and one predictor all inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    allow_nan_classes = [
+        MixinAllowNanNewTags,
+        MixinAllowNanOldNewTags,
+        MixinAllowNanOldTags,
+    ]
+
+    for allow_nan_cls in allow_nan_classes:
+        for predictor_cls in predictor_classes:
+
+            class ChildClass(allow_nan_cls, predictor_cls):
+                pass
+
+            with pytest.warns(FutureWarning, match=warn_msg):
+                tags = _safe_tags(ChildClass())
+
+            assert tags["allow_nan"]
+            assert tags["requires_fit"]
+
+    ####################################################################################
+    # two mixins and one predictor all inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    array_api_classes = [
+        MixinArrayApiSupportNewTags,
+        MixinArrayApiSupportOldNewTags,
+        MixinArrayApiSupportOldTags,
+    ]
+    allow_nan_classes = [
+        MixinAllowNanNewTags,
+        MixinAllowNanOldNewTags,
+        MixinAllowNanOldTags,
+    ]
+
+    for predictor_cls in predictor_classes:
+        for array_api_cls in array_api_classes:
+            for allow_nan_cls in allow_nan_classes:
+
+                class ChildClass(allow_nan_cls, array_api_cls, predictor_cls):
+                    pass
+
+                with pytest.warns(FutureWarning, match=warn_msg):
+                    tags = _safe_tags(ChildClass())
+
+                assert tags["allow_nan"]
+                assert tags["array_api_support"]
+                assert tags["requires_fit"]
+
+
+@pytest.mark.filterwarnings(
+    "ignore:.*Please define the `__sklearn_tags__` method.*:FutureWarning"
+)
+def test__get_tags_backward_compatibility():
+    warn_msg = "The `_get_tags` method is deprecated in 1.6"
+
+    ####################################################################################
+    # only predictor inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    for predictor_cls in predictor_classes:
+        with pytest.warns(FutureWarning, match=warn_msg):
+            tags = predictor_cls()._get_tags()
+        assert tags["requires_fit"]
+
+    ####################################################################################
+    # one mixin and one predictor all inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    allow_nan_classes = [
+        MixinAllowNanNewTags,
+        MixinAllowNanOldNewTags,
+        MixinAllowNanOldTags,
+    ]
+
+    for allow_nan_cls in allow_nan_classes:
+        for predictor_cls in predictor_classes:
+
+            class ChildClass(allow_nan_cls, predictor_cls):
+                pass
+
+            with pytest.warns(FutureWarning, match=warn_msg):
+                tags = ChildClass()._get_tags()
+
+            assert tags["allow_nan"]
+            assert tags["requires_fit"]
+
+    ####################################################################################
+    # two mixins and one predictor all inheriting from BaseEstimator
+    predictor_classes = [PredictorNewTags, PredictorOldNewTags, PredictorOldTags]
+    array_api_classes = [
+        MixinArrayApiSupportNewTags,
+        MixinArrayApiSupportOldNewTags,
+        MixinArrayApiSupportOldTags,
+    ]
+    allow_nan_classes = [
+        MixinAllowNanNewTags,
+        MixinAllowNanOldNewTags,
+        MixinAllowNanOldTags,
+    ]
+
+    for predictor_cls in predictor_classes:
+        for array_api_cls in array_api_classes:
+            for allow_nan_cls in allow_nan_classes:
+
+                class ChildClass(allow_nan_cls, array_api_cls, predictor_cls):
+                    pass
+
+                with pytest.warns(FutureWarning, match=warn_msg):
+                    tags = ChildClass()._get_tags()
+
+                assert tags["allow_nan"]
+                assert tags["array_api_support"]
+                assert tags["requires_fit"]
+
+
+def test_roundtrip_tags():
+    estimator = PredictorNewTags()
+    tags = default_tags(estimator)
+    assert _to_new_tags(_to_old_tags(tags), estimator=estimator) == tags
+
+
+def test_base_estimator_more_tags():
+    """Test that the `_more_tags` and `_get_tags` methods are equivalent for
+    `BaseEstimator`.
+    """
+    estimator = BaseEstimator()
+    with pytest.warns(FutureWarning, match="The `_more_tags` method is deprecated"):
+        more_tags = BaseEstimator._more_tags(estimator)
+
+    with pytest.warns(FutureWarning, match="The `_get_tags` method is deprecated"):
+        get_tags = BaseEstimator._get_tags(estimator)
+
+    assert more_tags == get_tags
+
+
+def test_safe_tags():
+    estimator = PredictorNewTags()
+    with pytest.warns(FutureWarning, match="The `_safe_tags` function is deprecated"):
+        tags = _safe_tags(estimator)
+
+    with pytest.warns(FutureWarning, match="The `_safe_tags` function is deprecated"):
+        tags_requires_fit = _safe_tags(estimator, key="requires_fit")
+
+    assert tags_requires_fit == tags["requires_fit"]
+
+    err_msg = "The key unknown_key is not defined"
+    with pytest.raises(ValueError, match=err_msg):
+        with pytest.warns(
+            FutureWarning, match="The `_safe_tags` function is deprecated"
+        ):
+            _safe_tags(estimator, key="unknown_key")
+
+
+def test_old_tags():
+    """Set to non-default values and check that we get the expected old tags."""
+
+    class MyClass:
+        _estimator_type = "regressor"
+
+        def __sklearn_tags__(self):
+            input_tags = InputTags(
+                one_d_array=True,
+                two_d_array=False,
+                three_d_array=True,
+                sparse=True,
+                categorical=True,
+                string=True,
+                dict=True,
+                positive_only=True,
+                allow_nan=True,
+                pairwise=True,
+            )
+            target_tags = TargetTags(
+                required=False,
+                one_d_labels=True,
+                two_d_labels=True,
+                positive_only=True,
+                multi_output=True,
+                single_output=False,
+            )
+            transformer_tags = None
+            classifier_tags = None
+            regressor_tags = RegressorTags(
+                poor_score=True,
+                multi_label=True,
+            )
+            return Tags(
+                estimator_type=self._estimator_type,
+                input_tags=input_tags,
+                target_tags=target_tags,
+                transformer_tags=transformer_tags,
+                classifier_tags=classifier_tags,
+                regressor_tags=regressor_tags,
+            )
+
+    estimator = MyClass()
+    new_tags = get_tags(estimator)
+    old_tags = _to_old_tags(new_tags)
+    expected_tags = {
+        "allow_nan": True,
+        "array_api_support": False,
+        "binary_only": False,
+        "multilabel": True,
+        "multioutput": True,
+        "multioutput_only": True,
+        "no_validation": False,
+        "non_deterministic": False,
+        "pairwise": True,
+        "preserves_dtype": ["float64"],
+        "poor_score": True,
+        "requires_fit": True,
+        "requires_positive_X": True,
+        "requires_y": False,
+        "requires_positive_y": True,
+        "_skip_test": False,
+        "stateless": True,
+        "X_types": [
+            "1darray",
+            "3darray",
+            "sparse",
+            "categorical",
+            "string",
+            "dict",
+            "1dlabels",
+            "2dlabels",
+        ],
+    }
+    assert old_tags == expected_tags
+    assert _to_new_tags(_to_old_tags(new_tags), estimator=estimator) == new_tags
+
+    class MyClass:
+        _estimator_type = "classifier"
+
+        def __sklearn_tags__(self):
+            input_tags = InputTags(
+                one_d_array=True,
+                two_d_array=False,
+                three_d_array=True,
+                sparse=True,
+                categorical=True,
+                string=True,
+                dict=True,
+                positive_only=True,
+                allow_nan=True,
+                pairwise=True,
+            )
+            target_tags = TargetTags(
+                required=False,
+                one_d_labels=True,
+                two_d_labels=False,
+                positive_only=True,
+                multi_output=True,
+                single_output=False,
+            )
+            transformer_tags = None
+            classifier_tags = ClassifierTags(
+                poor_score=True,
+                multi_class=False,
+                multi_label=True,
+            )
+            regressor_tags = None
+            return Tags(
+                estimator_type=self._estimator_type,
+                input_tags=input_tags,
+                target_tags=target_tags,
+                transformer_tags=transformer_tags,
+                classifier_tags=classifier_tags,
+                regressor_tags=regressor_tags,
+            )
+
+    estimator = MyClass()
+    new_tags = get_tags(estimator)
+    old_tags = _to_old_tags(new_tags)
+    expected_tags = {
+        "allow_nan": True,
+        "array_api_support": False,
+        "binary_only": True,
+        "multilabel": True,
+        "multioutput": True,
+        "multioutput_only": True,
+        "no_validation": False,
+        "non_deterministic": False,
+        "pairwise": True,
+        "preserves_dtype": ["float64"],
+        "poor_score": True,
+        "requires_fit": True,
+        "requires_positive_X": True,
+        "requires_y": False,
+        "requires_positive_y": True,
+        "_skip_test": False,
+        "stateless": True,
+        "X_types": [
+            "1darray",
+            "3darray",
+            "sparse",
+            "categorical",
+            "string",
+            "dict",
+            "1dlabels",
+        ],
+    }
+    assert old_tags == expected_tags
+    assert _to_new_tags(_to_old_tags(new_tags), estimator=estimator) == new_tags
+
+    class MyClass:
+
+        def fit(self, X, y=None):
+            return self  # pragma: no cover
+
+        def transform(self, X):
+            return X  # pragma: no cover
+
+        def __sklearn_tags__(self):
+            input_tags = InputTags(
+                one_d_array=True,
+                two_d_array=False,
+                three_d_array=True,
+                sparse=True,
+                categorical=True,
+                string=True,
+                dict=True,
+                positive_only=True,
+                allow_nan=True,
+                pairwise=True,
+            )
+            target_tags = TargetTags(
+                required=False,
+                one_d_labels=True,
+                two_d_labels=False,
+                positive_only=True,
+                multi_output=True,
+                single_output=False,
+            )
+            transformer_tags = TransformerTags(
+                preserves_dtype=["float64"],
+            )
+            classifier_tags = None
+            regressor_tags = None
+            return Tags(
+                estimator_type=None,
+                input_tags=input_tags,
+                target_tags=target_tags,
+                transformer_tags=transformer_tags,
+                classifier_tags=classifier_tags,
+                regressor_tags=regressor_tags,
+            )
+
+    estimator = MyClass()
+    new_tags = get_tags(estimator)
+    old_tags = _to_old_tags(new_tags)
+    expected_tags = {
+        "allow_nan": True,
+        "array_api_support": False,
+        "binary_only": False,
+        "multilabel": False,
+        "multioutput": True,
+        "multioutput_only": True,
+        "no_validation": False,
+        "non_deterministic": False,
+        "pairwise": True,
+        "preserves_dtype": ["float64"],
+        "poor_score": False,
+        "requires_fit": True,
+        "requires_positive_X": True,
+        "requires_y": False,
+        "requires_positive_y": True,
+        "_skip_test": False,
+        "stateless": True,
+        "X_types": [
+            "1darray",
+            "3darray",
+            "sparse",
+            "categorical",
+            "string",
+            "dict",
+            "1dlabels",
+        ],
+    }
+    assert old_tags == expected_tags
+    assert _to_new_tags(_to_old_tags(new_tags), estimator=estimator) == new_tags

MAINT conversion old->new/new->old tags
Towards #30298

This PR provides a way to convert old tags into new tags and new tags into old tags.
We should make sure that:

- An estimator implementing `_get_tags` or `_more_tags` get a warning but that the check_estimator should be working reasonably
- `_safe_tags` should raise a deprecation warning
- we should make sure that `get_tags` can get old tags and convert it to new tags. The tricky part is to detect whether `__sklearn_tags__` if present is implemented by the `BaseEstimator` from scikit-learn or the child class. If this is only the `BaseEstimator`, we should raise a warning to move to the new API and we should temporary use the `_get_tags`/`_safe_tags`, otherwise we are fine.

## ✔ Linting Passed
All linting checks passed. Your pull request is in excellent shape! ☀

<sub> _Generated for commit: [b41d1a6](https://github.com/scikit-learn/scikit-learn/pull/30302/commits/b41d1a6bafa25db5a7dc882ac21a6bdc131d744d). Link to the linter CI: [here](https://github.com/scikit-learn/scikit-learn/actions/runs/11942240084)_ </sub>
OK, so I'm converging with something working. I'm sorry for the reviewers of this PR... Trust the tests ;)

ping @jeremiedbb @ogrisel @adrinjalali
I'll add more test to cover the missing bits. I'll do it in a way to emulate old estimators.

module:utils
To backport
No Changelog Needed


diff --git a/sklearn/metrics/_regression.py b/sklearn/metrics/_regression.py
index 62251f9b96188..c5ebe67e34a2e 100644
--- a/sklearn/metrics/_regression.py
+++ b/sklearn/metrics/_regression.py
@@ -58,11 +58,16 @@
 def _check_reg_targets(y_true, y_pred, multioutput, dtype="numeric", xp=None):
     """Check that y_true and y_pred belong to the same regression task.

+    To reduce redundancy when calling `_find_matching_floating_dtype`,
+    please use `_check_reg_targets_with_floating_dtype` instead.
+
     Parameters
     ----------
-    y_true : array-like
+    y_true : array-like of shape (n_samples,) or (n_samples, n_outputs)
+        Ground truth (correct) target values.

-    y_pred : array-like
+    y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
+        Estimated target values.

     multioutput : array-like or string in ['raw_values', uniform_average',
         'variance_weighted'] or None
@@ -137,6 +142,71 @@ def _check_reg_targets(y_true, y_pred, multioutput, dtype="numeric", xp=None):
     return y_type, y_true, y_pred, multioutput

+def _check_reg_targets_with_floating_dtype(
+    y_true, y_pred, sample_weight, multioutput, xp=None
+):
+    """Ensures that y_true, y_pred, and sample_weight correspond to the same
+    regression task.
+
+    Extends `_check_reg_targets` by automatically selecting a suitable floating-point
+    data type for inputs using `_find_matching_floating_dtype`.
+
+    Use this private method only when converting inputs to array API-compatibles.
+
+    Parameters
+    ----------
+    y_true : array-like of shape (n_samples,) or (n_samples, n_outputs)
+        Ground truth (correct) target values.
+
+    y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
+        Estimated target values.
+
+    sample_weight : array-like of shape (n_samples,)
+
+    multioutput : array-like or string in ['raw_values', 'uniform_average', \
+        'variance_weighted'] or None
+        None is accepted due to backward compatibility of r2_score().
+
+    xp : module, default=None
+        Precomputed array namespace module. When passed, typically from a caller
+        that has already performed inspection of its own inputs, skips array
+        namespace inspection.
+
+    Returns
+    -------
+    type_true : one of {'continuous', 'continuous-multioutput'}
+        The type of the true target data, as output by
+        'utils.multiclass.type_of_target'.
+
+    y_true : array-like of shape (n_samples, n_outputs)
+        Ground truth (correct) target values.
+
+    y_pred : array-like of shape (n_samples, n_outputs)
+        Estimated target values.
+
+    sample_weight : array-like of shape (n_samples,), default=None
+        Sample weights.
+
+    multioutput : array-like of shape (n_outputs) or string in ['raw_values', \
+        'uniform_average', 'variance_weighted'] or None
+        Custom output weights if ``multioutput`` is array-like or
+        just the corresponding argument if ``multioutput`` is a
+        correct keyword.
+    """
+    dtype_name = _find_matching_floating_dtype(y_true, y_pred, sample_weight, xp=xp)
+
+    y_type, y_true, y_pred, multioutput = _check_reg_targets(
+        y_true, y_pred, multioutput, dtype=dtype_name, xp=xp
+    )
+
+    # _check_reg_targets does not accept sample_weight as input.
+    # Convert sample_weight's data type separately to match dtype_name.
+    if sample_weight is not None:
+        sample_weight = xp.asarray(sample_weight, dtype=dtype_name)
+
+    return y_type, y_true, y_pred, sample_weight, multioutput
+
+
 @validate_params(
     {
         "y_true": ["array-like"],
@@ -201,14 +271,14 @@ def mean_absolute_error(
     >>> mean_absolute_error(y_true, y_pred, multioutput=[0.3, 0.7])
     0.85...
     """
-    input_arrays = [y_true, y_pred, sample_weight, multioutput]
-    xp, _ = get_namespace(*input_arrays)
-
-    dtype = _find_matching_floating_dtype(y_true, y_pred, sample_weight, xp=xp)
+    xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)

-    _, y_true, y_pred, multioutput = _check_reg_targets(
-        y_true, y_pred, multioutput, dtype=dtype, xp=xp
+    _, y_true, y_pred, sample_weight, multioutput = (
+        _check_reg_targets_with_floating_dtype(
+            y_true, y_pred, sample_weight, multioutput, xp=xp
+        )
     )
+
     check_consistent_length(y_true, y_pred, sample_weight)

     output_errors = _average(
@@ -398,19 +468,16 @@ def mean_absolute_percentage_error(
     >>> mean_absolute_percentage_error(y_true, y_pred)
     112589990684262.48
     """
-    input_arrays = [y_true, y_pred, sample_weight, multioutput]
-    xp, _ = get_namespace(*input_arrays)
-    dtype = _find_matching_floating_dtype(y_true, y_pred, sample_weight, xp=xp)
-
-    y_type, y_true, y_pred, multioutput = _check_reg_targets(
-        y_true, y_pred, multioutput, dtype=dtype, xp=xp
+    xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
+    _, y_true, y_pred, sample_weight, multioutput = (
+        _check_reg_targets_with_floating_dtype(
+            y_true, y_pred, sample_weight, multioutput, xp=xp
+        )
     )
     check_consistent_length(y_true, y_pred, sample_weight)
-    epsilon = xp.asarray(xp.finfo(xp.float64).eps, dtype=dtype)
-    y_true_abs = xp.asarray(xp.abs(y_true), dtype=dtype)
-    mape = xp.asarray(xp.abs(y_pred - y_true), dtype=dtype) / xp.maximum(
-        y_true_abs, epsilon
-    )
+    epsilon = xp.asarray(xp.finfo(xp.float64).eps, dtype=y_true.dtype)
+    y_true_abs = xp.abs(y_true)
+    mape = xp.abs(y_pred - y_true) / xp.maximum(y_true_abs, epsilon)
     output_errors = _average(mape, weights=sample_weight, axis=0)
     if isinstance(multioutput, str):
         if multioutput == "raw_values":
@@ -494,10 +561,10 @@ def mean_squared_error(
     0.825...
     """
     xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
-    dtype = _find_matching_floating_dtype(y_true, y_pred, xp=xp)
-
-    _, y_true, y_pred, multioutput = _check_reg_targets(
-        y_true, y_pred, multioutput, dtype=dtype, xp=xp
+    _, y_true, y_pred, sample_weight, multioutput = (
+        _check_reg_targets_with_floating_dtype(
+            y_true, y_pred, sample_weight, multioutput, xp=xp
+        )
     )
     check_consistent_length(y_true, y_pred, sample_weight)
     output_errors = _average((y_true - y_pred) ** 2, axis=0, weights=sample_weight)
@@ -670,10 +737,9 @@ def mean_squared_log_error(
     0.060...
     """
     xp, _ = get_namespace(y_true, y_pred)
-    dtype = _find_matching_floating_dtype(y_true, y_pred, xp=xp)

-    _, y_true, y_pred, _ = _check_reg_targets(
-        y_true, y_pred, multioutput, dtype=dtype, xp=xp
+    _, y_true, y_pred, _, _ = _check_reg_targets_with_floating_dtype(
+        y_true, y_pred, sample_weight, multioutput, xp=xp
     )

     if xp.any(y_true <= -1) or xp.any(y_pred <= -1):
@@ -747,10 +813,9 @@ def root_mean_squared_log_error(
     0.199...
     """
     xp, _ = get_namespace(y_true, y_pred)
-    dtype = _find_matching_floating_dtype(y_true, y_pred, xp=xp)

-    _, y_true, y_pred, multioutput = _check_reg_targets(
-        y_true, y_pred, multioutput, dtype=dtype, xp=xp
+    _, y_true, y_pred, _, _ = _check_reg_targets_with_floating_dtype(
+        y_true, y_pred, sample_weight, multioutput, xp=xp
     )

     if xp.any(y_true <= -1) or xp.any(y_pred <= -1):
@@ -1188,11 +1253,12 @@ def r2_score(
         y_true, y_pred, sample_weight, multioutput
     )

-    dtype = _find_matching_floating_dtype(y_true, y_pred, sample_weight, xp=xp)
-
-    _, y_true, y_pred, multioutput = _check_reg_targets(
-        y_true, y_pred, multioutput, dtype=dtype, xp=xp
+    _, y_true, y_pred, sample_weight, multioutput = (
+        _check_reg_targets_with_floating_dtype(
+            y_true, y_pred, sample_weight, multioutput, xp=xp
+        )
     )
+
     check_consistent_length(y_true, y_pred, sample_weight)

     if _num_samples(y_pred) < 2:
@@ -1201,7 +1267,7 @@ def r2_score(
         return float("nan")

     if sample_weight is not None:
-        sample_weight = column_or_1d(sample_weight, dtype=dtype)
+        sample_weight = column_or_1d(sample_weight)
         weight = sample_weight[:, None]
     else:
         weight = 1.0
@@ -1356,8 +1422,8 @@ def mean_tweedie_deviance(y_true, y_pred, *, sample_weight=None, power=0):
     1.4260...
     """
     xp, _ = get_namespace(y_true, y_pred)
-    y_type, y_true, y_pred, _ = _check_reg_targets(
-        y_true, y_pred, None, dtype=[xp.float64, xp.float32], xp=xp
+    y_type, y_true, y_pred, sample_weight, _ = _check_reg_targets_with_floating_dtype(
+        y_true, y_pred, sample_weight, multioutput=None, xp=xp
     )
     if y_type == "continuous-multioutput":
         raise ValueError("Multioutput not supported in mean_tweedie_deviance")
@@ -1570,8 +1636,8 @@ def d2_tweedie_score(y_true, y_pred, *, sample_weight=None, power=0):
     """
     xp, _ = get_namespace(y_true, y_pred)

-    y_type, y_true, y_pred, _ = _check_reg_targets(
-        y_true, y_pred, None, dtype=[xp.float64, xp.float32], xp=xp
+    y_type, y_true, y_pred, sample_weight, _ = _check_reg_targets_with_floating_dtype(
+        y_true, y_pred, sample_weight, multioutput=None, xp=xp
     )
     if y_type == "continuous-multioutput":
         raise ValueError("Multioutput not supported in d2_tweedie_score")

diff --git a/sklearn/metrics/tests/test_common.py b/sklearn/metrics/tests/test_common.py
index e6abc8c433013..30722c5eab52f 100644
--- a/sklearn/metrics/tests/test_common.py
+++ b/sklearn/metrics/tests/test_common.py
@@ -583,8 +583,8 @@ def _require_positive_targets(y1, y2):
 def _require_log1p_targets(y1, y2):
     """Make targets strictly larger than -1"""
     offset = abs(min(y1.min(), y2.min())) - 0.99
-    y1 = y1.astype(float)
-    y2 = y2.astype(float)
+    y1 = y1.astype(np.float64)
+    y2 = y2.astype(np.float64)
     y1 += offset
     y2 += offset
     return y1, y2

Reduce redundancy in floating type checks for Array API support
### Describe the workflow you want to enable

While working on #29978, we noticed that the following procedure is repeated across most regression metrics in `_regression.py` for the Array API:

```python
    xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
    dtype = _find_matching_floating_dtype(y_true, y_pred, xp=xp)

    _, y_true, y_pred, multioutput = _check_reg_targets(
        y_true, y_pred, multioutput, dtype=dtype, xp=xp
    )
```

To reduce redundancy, it would make sense to incorporate the `_find_matching_floating_dtype` logic directly into the `_check_reg_targets` function. This would result in the following cleaner implementation:

```python
    xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
    _, y_true, y_pred, multioutput, dtype = _check_reg_targets(
        y_true, y_pred, multioutput, xp=xp
    )
```

### Describe your proposed solution

We could introduce a new function, `_check_reg_targets_and_dtype`, defined in the obvious way. This approach would enable us to utilise the existing tests in `test_regression.py` with minimal changes.

### Describe alternatives you've considered, if relevant

We could modify the original `_check_reg_targets` function, but this would require carefully reviewing and updating the relevant tests in `test_regression.py` to ensure everything remains consistent.

### Additional context

This is part of the Array API project #26024.

ping: @ogrisel
cc: @glemaitre, @betatim, @adrinjalali.

That sounds good to me.
Hi @virchan , @adrinjalali

Will it be okay if I try to work on this?
> Hi @virchan , @adrinjalali
>
> Will it be okay if I try to work on this?

Hello @sqali,

This issue is currently affecting another PR I'm working on, so I feel obligated to resolve it myself. I plan to start working on it once the proposed solution is approved.

That said, your review of the PR would still be very much appreciated to catch any mistakes or suggest improvements. I'll make sure to CC you when the PR is created.
Thanks @virchan

module:metrics
No Changelog Needed
Array API


diff --git a/sklearn/metrics/_classification.py b/sklearn/metrics/_classification.py
index 0175966a21b74..b69855633a730 100644
--- a/sklearn/metrics/_classification.py
+++ b/sklearn/metrics/_classification.py
@@ -1727,14 +1727,18 @@ def precision_recall_fscore_support(
         "assigned" 0 samples. For multilabel targets, labels are column indices.
         By default, all labels in `y_true` and `y_pred` are used in sorted order.

+        .. versionchanged:: 0.17
+           Parameter `labels` improved for multiclass problem.
+
     pos_label : int, float, bool or str, default=1
         The class to report if `average='binary'` and the data is binary,
         otherwise this parameter is ignored.
         For multiclass or multilabel targets, set `labels=[pos_label]` and
         `average != 'binary'` to report metrics for one label only.

-    average : {'binary', 'micro', 'macro', 'samples', 'weighted'}, \
-            default=None
+    average : {'micro', 'macro', 'samples', 'weighted', 'binary'} or None, \
+            default='binary'
+        This parameter is required for multiclass/multilabel targets.
         If ``None``, the metrics for each class are returned. Otherwise, this
         determines the type of averaging performed on the data:

diff --git a/sklearn/tests/test_docstring_parameters.py b/sklearn/tests/test_docstring_parameters.py
index 3af463b783bc3..ffa4fe63967f9 100644
--- a/sklearn/tests/test_docstring_parameters.py
+++ b/sklearn/tests/test_docstring_parameters.py
@@ -12,6 +12,7 @@
 import pytest

 import sklearn
+from sklearn import metrics
 from sklearn.datasets import make_classification

 # make it possible to discover experimental estimators when calling `all_estimators`
@@ -24,6 +25,7 @@
 from sklearn.utils import all_estimators
 from sklearn.utils._testing import (
     _get_func_name,
+    assert_docstring_consistency,
     check_docstring_parameters,
     ignore_warnings,
 )
@@ -320,3 +322,26 @@ def _get_all_fitted_attributes(estimator):
             fit_attr.append(name)

     return [k for k in fit_attr if k.endswith("_") and not k.startswith("_")]
+
+
+def test_precision_recall_f_score_docstring_consistency():
+    """Check docstrings parameters of related metrics are consistent."""
+    pytest.importorskip(
+        "numpydoc",
+        reason="numpydoc is required to test the docstrings",
+    )
+    assert_docstring_consistency(
+        [
+            metrics.precision_recall_fscore_support,
+            metrics.f1_score,
+            metrics.fbeta_score,
+            metrics.precision_score,
+            metrics.recall_score,
+        ],
+        include_params=True,
+        # "average" - in `recall_score` we have an additional line: 'Weighted recall
+        # is equal to accuracy.'.
+        # "zero_division" - the reason for zero division differs between f scores,
+        # precison and recall.
+        exclude_params=["average", "zero_division"],
+    )
diff --git a/sklearn/utils/_testing.py b/sklearn/utils/_testing.py
index 1ec38edd20e83..d2a784494e5cd 100644
--- a/sklearn/utils/_testing.py
+++ b/sklearn/utils/_testing.py
@@ -14,12 +14,16 @@
 import shutil
 import sys
 import tempfile
+import textwrap
 import unittest
 import warnings
+from collections import defaultdict, namedtuple
 from collections.abc import Iterable
 from dataclasses import dataclass
+from difflib import context_diff
 from functools import wraps
 from inspect import signature
+from itertools import chain, groupby
 from subprocess import STDOUT, CalledProcessError, TimeoutExpired, check_output

 import joblib
@@ -605,6 +609,207 @@ def check_docstring_parameters(func, doc=None, ignore=None):
     return incorrect

+def _check_item_included(item_name, args):
+    """Helper to check if item should be included in checking."""
+    if args.include is not True and item_name not in args.include:
+        return False
+    if args.exclude is not None and item_name in args.exclude:
+        return False
+    return True
+
+
+def _diff_key(line):
+    """Key for grouping output from `context_diff`."""
+    if line.startswith("  "):
+        return "  "
+    elif line.startswith("- "):
+        return "- "
+    elif line.startswith("+ "):
+        return "+ "
+    elif line.startswith("! "):
+        return "! "
+    return None
+
+
+def _get_diff_msg(docstrings_grouped):
+    """Get message showing the difference between type/desc docstrings of all objects.
+
+    `docstrings_grouped` keys should be the type/desc docstrings and values are a list
+    of objects with that docstring. Objects with the same type/desc docstring are
+    thus grouped together.
+    """
+    msg_diff = ""
+    ref_str = ""
+    ref_group = []
+    for docstring, group in docstrings_grouped.items():
+        if not ref_str and not ref_group:
+            ref_str += docstring
+            ref_group.extend(group)
+        diff = list(
+            context_diff(
+                ref_str.split(),
+                docstring.split(),
+                fromfile=str(ref_group),
+                tofile=str(group),
+                n=8,
+            )
+        )
+        # Add header
+        msg_diff += "".join((diff[:3]))
+        # Group consecutive 'diff' words to shorten error message
+        for start, group in groupby(diff[3:], key=_diff_key):
+            if start is None:
+                msg_diff += "\n" + "\n".join(group)
+            else:
+                msg_diff += "\n" + start + " ".join(word[2:] for word in group)
+        # Add new lines at end of diff, to separate comparisons
+        msg_diff += "\n\n"
+    return msg_diff
+
+
+def _check_consistency_items(items_docs, type_or_desc, section, n_objects):
+    """Helper to check docstring consistency of all `items_docs`.
+
+    If item is not present in all objects, checking is skipped and warning raised.
+    """
+    skipped = []
+    for item_name, docstrings_grouped in items_docs.items():
+        # If item not found in all objects, skip
+        if sum([len(objs) for objs in docstrings_grouped.values()]) < n_objects:
+            skipped.append(item_name)
+        # If more than one key, docstrings not consistent between objects
+        elif len(docstrings_grouped.keys()) > 1:
+            msg_diff = _get_diff_msg(docstrings_grouped)
+            obj_groups = " and ".join(
+                str(group) for group in docstrings_grouped.values()
+            )
+            msg = textwrap.fill(
+                f"The {type_or_desc} of {section[:-1]} '{item_name}' is inconsistent "
+                f"between {obj_groups}:"
+            )
+            msg += msg_diff
+            raise AssertionError(msg)
+    if skipped:
+        warnings.warn(
+            f"Checking was skipped for {section}: {skipped} as they were "
+            "not found in all objects."
+        )
+
+
+def assert_docstring_consistency(
+    objects,
+    include_params=False,
+    exclude_params=None,
+    include_attrs=False,
+    exclude_attrs=None,
+    include_returns=False,
+    exclude_returns=None,
+):
+    """Check consistency between docstring parameters/attributes/returns of objects.
+
+    Checks if parameters/attributes/returns have the same type specification and
+    description (ignoring whitespace) across `objects`. Intended to be used for
+    related classes/functions/data descriptors.
+
+    Entries that do not appear across all `objects` are ignored.
+
+    Parameters
+    ----------
+    objects : list of {classes, functions, data descriptors}
+        Objects to check.
+        Objects may be classes, functions or data descriptors with docstrings that
+        can be parsed by numpydoc.
+
+    include_params : list of str or bool, default=False
+        List of parameters to be included. If True, all parameters are included,
+        if False, checking is skipped for parameters.
+        Can only be set if `exclude_params` is None.
+
+    exclude_params : list of str or None, default=None
+        List of parameters to be excluded. If None, no parameters are excluded.
+        Can only be set if `include_params` is True.
+
+    include_attrs : list of str or bool, default=False
+        List of attributes to be included. If True, all attributes are included,
+        if False, checking is skipped for attributes.
+        Can only be set if `exclude_attrs` is None.
+
+    exclude_attrs : list of str or None, default=None
+        List of attributes to be excluded. If None, no attributes are excluded.
+        Can only be set if `include_attrs` is True.
+
+    include_returns : list of str or bool, default=False
+        List of returns to be included. If True, all returns are included,
+        if False, checking is skipped for returns.
+        Can only be set if `exclude_returns` is None.
+
+    exclude_returns : list of str or None, default=None
+        List of returns to be excluded. If None, no returns are excluded.
+        Can only be set if `include_returns` is True.
+
+    Examples
+    --------
+    >>> from sklearn.metrics import (mean_absolute_error, mean_squared_error,
+    ... median_absolute_error)
+    >>> from sklearn.utils.testing import assert_docstring_consistency
+    ... # doctest: +SKIP
+    >>> assert_docstring_consistency([mean_absolute_error, mean_squared_error],
+    ... include_params=['y_true', 'y_pred', 'sample_weight'])  # doctest: +SKIP
+    >>> assert_docstring_consistency([median_absolute_error, mean_squared_error],
+    ... include_params=True)  # doctest: +SKIP
+    """
+    from numpydoc import docscrape
+
+    Args = namedtuple("args", ["include", "exclude", "arg_name"])
+
+    def _create_args(include, exclude, arg_name, section_name):
+        if exclude and include is not True:
+            raise TypeError(
+                f"The 'exclude_{arg_name}' argument can be set only when the "
+                f"'include_{arg_name}' argument is True."
+            )
+        if include is False:
+            return {}
+        return {section_name: Args(include, exclude, arg_name)}
+
+    section_args = {
+        **_create_args(include_params, exclude_params, "params", "Parameters"),
+        **_create_args(include_attrs, exclude_attrs, "attrs", "Attributes"),
+        **_create_args(include_returns, exclude_returns, "returns", "Returns"),
+    }
+
+    objects_doc = dict()
+    for obj in objects:
+        if (
+            inspect.isdatadescriptor(obj)
+            or inspect.isfunction(obj)
+            or inspect.isclass(obj)
+        ):
+            objects_doc[obj.__name__] = docscrape.NumpyDocString(inspect.getdoc(obj))
+        else:
+            raise TypeError(
+                "All 'objects' must be one of: function, class or descriptor, "
+                f"got a: {type(obj)}."
+            )
+
+    n_objects = len(objects)
+    for section, args in section_args.items():
+        type_items = defaultdict(lambda: defaultdict(list))
+        desc_items = defaultdict(lambda: defaultdict(list))
+        for obj_name, obj_doc in objects_doc.items():
+            for item_name, type_def, desc in obj_doc[section]:
+                if _check_item_included(item_name, args):
+                    # Normalize white space
+                    type_def = " ".join(type_def.strip().split())
+                    desc = " ".join(chain.from_iterable(line.split() for line in desc))
+                    # Use string type/desc as key, to group consistent objs together
+                    type_items[item_name][type_def].append(obj_name)
+                    desc_items[item_name][desc].append(obj_name)
+
+        _check_consistency_items(type_items, "type specification", section, n_objects)
+        _check_consistency_items(desc_items, "description", section, n_objects)
+
+
 def assert_run_python_script_without_output(source_code, pattern=".+", timeout=60):
     """Utility to check assertions in an independent Python subprocess.

diff --git a/sklearn/utils/tests/test_testing.py b/sklearn/utils/tests/test_testing.py
index 4e7a40dae1222..08ded6e404924 100644
--- a/sklearn/utils/tests/test_testing.py
+++ b/sklearn/utils/tests/test_testing.py
@@ -15,6 +15,7 @@
     _get_warnings_filters_info_list,
     assert_allclose,
     assert_allclose_dense_sparse,
+    assert_docstring_consistency,
     assert_run_python_script_without_output,
     check_docstring_parameters,
     create_memmap_backed_data,
@@ -404,7 +405,6 @@ def test_check_docstring_parameters():
         reason="numpydoc is required to test the docstrings",
         minversion="1.2.0",
     )
-
     incorrect = check_docstring_parameters(f_ok)
     assert incorrect == []
     incorrect = check_docstring_parameters(f_ok, ignore=["b"])
@@ -534,6 +534,269 @@ def test_check_docstring_parameters():
         assert msg == incorrect, '\n"%s"\n not in \n"%s"' % (msg, incorrect)

+def f_one(a, b):  # pragma: no cover
+    """Function one.
+
+    Parameters
+    ----------
+    a : int,   float
+        Parameter a.
+        Second    line.
+
+    b : str
+        Parameter b.
+
+    Returns
+    -------
+    c : int
+       Returning
+
+    d : int
+       Returning
+    """
+    pass
+
+
+def f_two(a, b):  # pragma: no cover
+    """Function two.
+
+    Parameters
+    ----------
+    a :   int, float
+        Parameter a.
+          Second line.
+
+    b : str
+        Parameter bb.
+
+    e : int
+        Extra parameter.
+
+    Returns
+    -------
+    c : int
+       Returning
+
+    d : int
+       Returning
+    """
+    pass
+
+
+def f_three(a, b):  # pragma: no cover
+    """Function two.
+
+    Parameters
+    ----------
+    a :   int, float
+        Parameter a.
+
+    b : str
+        Parameter B!
+
+    e :
+        Extra parameter.
+
+    Returns
+    -------
+    c : int
+       Returning.
+
+    d : int
+       Returning
+    """
+    pass
+
+
+def test_assert_docstring_consistency_object_type():
+    """Check error raised when `objects` incorrect type."""
+    pytest.importorskip(
+        "numpydoc",
+        reason="numpydoc is required to test the docstrings",
+    )
+    with pytest.raises(TypeError, match="All 'objects' must be one of"):
+        assert_docstring_consistency(["string", f_one])
+
+
+@pytest.mark.parametrize(
+    "objects, kwargs, error",
+    [
+        (
+            [f_one, f_two],
+            {"include_params": ["a"], "exclude_params": ["b"]},
+            "The 'exclude_params' argument",
+        ),
+        (
+            [f_one, f_two],
+            {"include_returns": False, "exclude_returns": ["c"]},
+            "The 'exclude_returns' argument",
+        ),
+    ],
+)
+def test_assert_docstring_consistency_arg_checks(objects, kwargs, error):
+    """Check `assert_docstring_consistency` argument checking correct."""
+    pytest.importorskip(
+        "numpydoc",
+        reason="numpydoc is required to test the docstrings",
+    )
+    with pytest.raises(TypeError, match=error):
+        assert_docstring_consistency(objects, **kwargs)
+
+
+@pytest.mark.parametrize(
+    "objects, kwargs, error, warn",
+    [
+        pytest.param(
+            [f_one, f_two], {"include_params": ["a"]}, "", "", id="whitespace"
+        ),
+        pytest.param([f_one, f_two], {"include_returns": True}, "", "", id="incl_all"),
+        pytest.param(
+            [f_one, f_two, f_three],
+            {"include_params": ["a"]},
+            (
+                r"The description of Parameter 'a' is inconsistent between "
+                r"\['f_one',\n'f_two'\]"
+            ),
+            "",
+            id="2-1 group",
+        ),
+        pytest.param(
+            [f_one, f_two, f_three],
+            {"include_params": ["b"]},
+            (
+                r"The description of Parameter 'b' is inconsistent between "
+                r"\['f_one'\] and\n\['f_two'\] and"
+            ),
+            "",
+            id="1-1-1 group",
+        ),
+        pytest.param(
+            [f_two, f_three],
+            {"include_params": ["e"]},
+            (
+                r"The type specification of Parameter 'e' is inconsistent between\n"
+                r"\['f_two'\] and"
+            ),
+            "",
+            id="empty type",
+        ),
+        pytest.param(
+            [f_one, f_two],
+            {"include_params": True, "exclude_params": ["b"]},
+            "",
+            r"Checking was skipped for Parameters: \['e'\]",
+            id="skip warn",
+        ),
+    ],
+)
+def test_assert_docstring_consistency(objects, kwargs, error, warn):
+    """Check `assert_docstring_consistency` gives correct results."""
+    pytest.importorskip(
+        "numpydoc",
+        reason="numpydoc is required to test the docstrings",
+    )
+    if error:
+        with pytest.raises(AssertionError, match=error):
+            assert_docstring_consistency(objects, **kwargs)
+    elif warn:
+        with pytest.warns(UserWarning, match=warn):
+            assert_docstring_consistency(objects, **kwargs)
+    else:
+        assert_docstring_consistency(objects, **kwargs)
+
+
+def f_four(labels):  # pragma: no cover
+    """Function four.
+
+    Parameters
+    ----------
+
+    labels : array-like, default=None
+        The set of labels to include when `average != 'binary'`, and their
+        order if `average is None`. Labels present in the data can be excluded.
+    """
+    pass
+
+
+def f_five(labels):  # pragma: no cover
+    """Function five.
+
+    Parameters
+    ----------
+
+    labels : array-like, default=None
+        The set of labels to include when `average != 'binary'`, and their
+        order if `average is None`. This is an extra line. Labels present in the
+        data can be excluded.
+    """
+    pass
+
+
+def f_six(labels):  # pragma: no cover
+    """Function six.
+
+    Parameters
+    ----------
+
+    labels : array-like, default=None
+        The group of labels to add when `average != 'binary'`, and the
+        order if `average is None`. Labels present on them datas can be excluded.
+    """
+    pass
+
+
+def test_assert_docstring_consistency_error_msg():
+    """Check `assert_docstring_consistency` difference message."""
+    pytest.importorskip(
+        "numpydoc.docscrape",
+        reason="numpydoc is required to test the docstrings",
+    )
+    msg = r"""The description of Parameter 'labels' is inconsistent between
+\['f_four'\] and \['f_five'\] and \['f_six'\]:
+
+\*\*\* \['f_four'\]
+--- \['f_five'\]
+\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*
+
+\*\*\* 10,25 \*\*\*\*
+
+--- 10,30 ----
+
+  'binary'`, and their order if `average is None`.
+\+ This is an extra line.
+  Labels present in the data can be excluded.
+
+\*\*\* \['f_four'\]
+--- \['f_six'\]
+\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*
+
+\*\*\* 1,25 \*\*\*\*
+
+  The
+! set
+  of labels to
+! include
+  when `average != 'binary'`, and
+! their
+  order if `average is None`. Labels present
+! in the data
+  can be excluded.
+--- 1,25 ----
+
+  The
+! group
+  of labels to
+! add
+  when `average != 'binary'`, and
+! the
+  order if `average is None`. Labels present
+! on them datas
+  can be excluded."""
+
+    with pytest.raises(AssertionError, match=msg):
+        assert_docstring_consistency([f_four, f_five, f_six], include_params=True)
+
+
 class RegistrationCounter:
     def __init__(self):
         self.nb_calls = 0

[WIP] : Added assert_consistent_docs() and related tests

Fixes #9388

Added a function to check for consistency between docstring of objects.

In this approach there is a python dictionary for each of Parameters, Attributes and Returns. Indexed by the name of the parameter/attribute/return with the value being a Python dictionary containing its type definition and description. The function checks for each object if the docstring is identical (excluding whitespaces) for a given parameter/attribute/return in the main dictionary. If a new parameter/attribute/return are found they are added in the main dictionary.

Do we expect this to be reasonable for everything, or only for parts of the library? something like ``random_state`` might or might not be good to document consistently - saying what the randomness is might be good. Also, we have parameters like ``alpha`` that can mean basically anything
Or is the idea to call this with very small families, like "only linear models" or something like that?
numpydoc is not found? (are we not using numpydoc on master... I lost track of that)
can you choose a few related objects from one module and add tests for
their parameters as an example?

In `sklearn/tests/test_docstring_parameters.py` we skip the test if numpydoc is not found. We should do so here also. Only one of our Travis runs installs numpydoc for testing.
Yes, Andy, the intention is to use this for families of objects, e.g. `precision_score`, `recall_score`, `f1_score`, etc..
The doctest for the example is failing as I have used `try` and `except` for `import numpydoc` in the unit test and not in the function itself. What should I do?
do the import locally in the function is one easy solution

The doctest is still failing with
`UNEXPECTED EXCEPTION: SkipTest('numpydoc is required to test the docstrings, as well as python version >= 3.5',)`
Should we skip the doctest?
I think just skip, or remove, the doctest.

On 17 December 2017 at 16:11, Aman Pratik <notifications@github.com> wrote:

> The doctest is still failing with
> UNEXPECTED EXCEPTION: SkipTest('numpydoc is required to test the
> docstrings, as well as python version >= 3.5',)
> Should we skip the doctest?
>
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub
> <https://github.com/scikit-learn/scikit-learn/pull/10323#issuecomment-352232808>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AAEz61TOYUDWAaKm6cvUFC7AjYZ3ggi2ks5tBKJngaJpZM4RCetf>
> .
>

I will add more tests to improve the coverage shortly.

After seeing the coverage report I feel because of `numpydoc` not being available many tests are not running, hence the bad coverage.
What do you say @jnothman  @amueller ?
@jnothman  What are we supposed to do finally about the include and exclude concept?
As I believe,
1. default for include can be set to '*'.
2. We can keep it mandatory to set either include or exclude only.
Let's get rid of '*' and replace it with True:
* Include=True by default
* Include=False disables that section
* Include=collection tests only named elements
* Exclude=None (equivalent to ()) by default
* Exclude otherwise can only be set if include is True

Yes, make a helper to only include a test when docstring testing is enabled
@jnothman I have made the changes and added tests. Need your opinion on the tests.
I have added validation test for include_ and exclude_ , but I need your opinion on it. Also, I am not sure on how to test the corner cases. Few examples might help.
I have tried to change the tests accordingly. I have used `assert_raise_message` instead of `assert_raises_regex` since I was having difficulty with regular expressions.
I will have to work on the edge cases. I get confused sometimes on which tests would be reasonable and which wont.
it's a hard balance to get right, and you get better with practice. writing
tests before implementation, and extending them during implementation helps
think about how to assert desired functionality.

Good tests should IMO look a bit like a proof by induction. You test the
base case, and then assume everything like what's been tested so far works,
upon which you extend with variants (different parameters etc)

Well, the code would show an error on the very first inconsistency it finds. The error message would be enough to locate the exact place of inconsistency i.e. the error message shows the Parameter name, name of the concerned objects and error type(type definition or description).
@glemaitre @scikit-learn/documentation-team what do we think of this now? There are a few usecases, worth continuing this work?
I guess this PR was already looking in good shape, so the extra step may be worth it.
+1, I think this is useful
@lucyleeow @ArturoAmorQ would you have bandwidth to push this forward? pretty please? 😁
I'm happy to work on this 😄
Please feel free to ping me as well if you need reviews :)
Yep this looks like a step ahead for consistency and having the right docstring.

module:metrics
module:utils
No Changelog Needed
Waiting for Second Reviewer


diff --git a/src/prefect/tasks.py b/src/prefect/tasks.py
index d4e7d2fbce52..0d6610009847 100644
--- a/src/prefect/tasks.py
+++ b/src/prefect/tasks.py
@@ -474,6 +474,10 @@ def __init__(

         if callable(retry_delay_seconds):
             self.retry_delay_seconds = retry_delay_seconds(retries)
+        elif not isinstance(retry_delay_seconds, (list, int, float, type(None))):
+            raise TypeError(
+                f"Invalid `retry_delay_seconds` provided; must be an int, float, list or callable. Received type {type(retry_delay_seconds)}"
+            )
         else:
             self.retry_delay_seconds = retry_delay_seconds

diff --git a/tests/test_tasks.py b/tests/test_tasks.py
index 88cc1c4d086a..06d41abd6423 100644
--- a/tests/test_tasks.py
+++ b/tests/test_tasks.py
@@ -4172,6 +4172,21 @@ def my_flow():

 class TestTaskConstructorValidation:
+    async def test_task_cannot_configure_poorly_typed_retry_delay(self):
+        with pytest.raises(TypeError, match="Invalid"):
+
+            @task(retries=42, retry_delay_seconds=dict(x=4))
+            async def insanity():
+                raise RuntimeError("try again!")
+
+        with pytest.raises(TypeError, match="Invalid"):
+
+            @task(retries=42, retry_delay_seconds=2)
+            async def sanity():
+                raise RuntimeError("try again!")
+
+            more_insanity = sanity.with_options(retry_delay_seconds=dict(x=4))  # noqa: F841
+
     async def test_task_cannot_configure_too_many_custom_retry_delays(self):
         with pytest.raises(ValueError, match="Can not configure more"):

Missconfigured retry_delay_seconds task argument leads to confusing error message
### Bug summary

When the task arg `retry_delay_seconds` is not configured with the correct type, the error message presented is misleading:
```python
from datetime import timedelta
from prefect import get_run_logger, task, flow

@task(retries=1, retry_delay_seconds=timedelta(seconds=1)) #  retry_delay_seconds should be something like an int
def tasky():
    logger = get_run_logger()
    logger.info("hi")

@flow
def flowy():
    tasky()

flowy()
```

The resulting error message is:
```
09:33:08.239 | ERROR   | Flow run 'private-bittern' - Crash detected! Execution was interrupted by an unexpected exception: ValidationError: 2 validation errors for TaskRunPolicy
retry_delay
  value is not a valid integer (type=type_error.integer)
retry_delay
  value is not a valid list (type=type_error.list)
```

Notice is mentions `retry_delay`, not `retry_delay_seconds`

### Version info

```Text
Version:             2.20.13
API version:         0.8.4
Python version:      3.10.13
Git commit:          4d50c394
Built:               Wed, Nov 13, 2024 8:48 AM
OS/Arch:             darwin/arm64
Profile:             default
Server type:         cloud
```


diff --git a/src/prefect/task_engine.py b/src/prefect/task_engine.py
index a07f2108c95a..d6b834d6bca6 100644
--- a/src/prefect/task_engine.py
+++ b/src/prefect/task_engine.py
@@ -600,6 +600,7 @@ def handle_timeout(self, exc: TimeoutError) -> None:
                 message=message,
                 name="TimedOut",
             )
+            self.record_terminal_state_timing(state)
             self.set_state(state)
             self._raised = exc

@@ -1134,6 +1135,7 @@ async def handle_timeout(self, exc: TimeoutError) -> None:
                 message=message,
                 name="TimedOut",
             )
+            self.record_terminal_state_timing(state)
             await self.set_state(state)
             self._raised = exc
             self._telemetry.end_span_on_failure(state.message)

diff --git a/tests/test_task_engine.py b/tests/test_task_engine.py
index 185045fb9455..c4f1847b160c 100644
--- a/tests/test_task_engine.py
+++ b/tests/test_task_engine.py
@@ -1511,6 +1511,58 @@ async def foo():
         assert run.end_time == failed.timestamp
         assert run.total_run_time == failed.timestamp - running.timestamp

+    async def test_sync_task_sets_end_time_on_failed_timedout(
+        self, prefect_client, events_pipeline
+    ):
+        ID = None
+
+        @task
+        def foo():
+            nonlocal ID
+            ID = TaskRunContext.get().task_run.id
+            raise TimeoutError
+
+        with pytest.raises(TimeoutError):
+            run_task_sync(foo)
+
+        await events_pipeline.process_events()
+
+        run = await prefect_client.read_task_run(ID)
+
+        states = await prefect_client.read_task_run_states(ID)
+        running = [state for state in states if state.type == StateType.RUNNING][0]
+        failed = [state for state in states if state.type == StateType.FAILED][0]
+
+        assert failed.name == "TimedOut"
+        assert run.end_time
+        assert run.end_time == failed.timestamp
+        assert run.total_run_time == failed.timestamp - running.timestamp
+
+    async def test_async_task_sets_end_time_on_failed_timedout(
+        self, prefect_client, events_pipeline
+    ):
+        ID = None
+
+        @task
+        async def foo():
+            nonlocal ID
+            ID = TaskRunContext.get().task_run.id
+            raise TimeoutError
+
+        with pytest.raises(TimeoutError):
+            await run_task_async(foo)
+
+        await events_pipeline.process_events()
+        run = await prefect_client.read_task_run(ID)
+        states = await prefect_client.read_task_run_states(ID)
+        running = [state for state in states if state.type == StateType.RUNNING][0]
+        failed = [state for state in states if state.type == StateType.FAILED][0]
+
+        assert failed.name == "TimedOut"
+        assert run.end_time
+        assert run.end_time == failed.timestamp
+        assert run.total_run_time == failed.timestamp - running.timestamp
+
     async def test_sync_task_sets_end_time_on_crashed(
         self, prefect_client, events_pipeline
     ):

`TimedOut` task has no duration or end time
### Bug summary

 Using the following sample flow to produce a `TimedOut` task and flow, the flow run receives a duration and end time but the task run does not.

```python
from prefect import flow, task
import requests

@task(timeout_seconds=5)
def timeout_task():
    requests.get("http://httpbin.org/delay/10")

@flow
def timeout_flow():
    timeout_task()

if __name__ == "__main__":
    timeout_flow()
```

### Version info

```Text
Version:             3.1.0
API version:         0.8.4
Python version:      3.12.4
Git commit:          a83ba39b
Built:               Thu, Oct 31, 2024 12:43 PM
OS/Arch:             darwin/arm64
Profile:             default
Server type:         cloud
Pydantic version:    2.9.1
```

### Additional context

_No response_


diff --git a/src/prefect/task_engine.py b/src/prefect/task_engine.py
index 43238d1cbc6c..5accda613f34 100644
--- a/src/prefect/task_engine.py
+++ b/src/prefect/task_engine.py
@@ -427,6 +427,11 @@ def set_state(self, state: State, force: bool = False) -> State:

         self.task_run.state = new_state = state

+        if last_state.timestamp == new_state.timestamp:
+            # Ensure that the state timestamp is unique, or at least not equal to the last state.
+            # This might occur especially on Windows where the timestamp resolution is limited.
+            new_state.timestamp += pendulum.duration(microseconds=1)
+
         # Ensure that the state_details are populated with the current run IDs
         new_state.state_details.task_run_id = self.task_run.id
         new_state.state_details.flow_run_id = self.task_run.flow_run_id
@@ -942,6 +947,11 @@ async def set_state(self, state: State, force: bool = False) -> State:

         self.task_run.state = new_state = state

+        if last_state.timestamp == new_state.timestamp:
+            # Ensure that the state timestamp is unique, or at least not equal to the last state.
+            # This might occur especially on Windows where the timestamp resolution is limited.
+            new_state.timestamp += pendulum.duration(microseconds=1)
+
         # Ensure that the state_details are populated with the current run IDs
         new_state.state_details.task_run_id = self.task_run.id
         new_state.state_details.flow_run_id = self.task_run.flow_run_id

diff --git a/tests/test_task_engine.py b/tests/test_task_engine.py
index c6702f6a11de..185045fb9455 100644
--- a/tests/test_task_engine.py
+++ b/tests/test_task_engine.py
@@ -7,7 +7,7 @@
 from pathlib import Path
 from typing import List, Optional
 from unittest import mock
-from unittest.mock import AsyncMock, MagicMock, call
+from unittest.mock import AsyncMock, MagicMock, call, patch
 from uuid import UUID, uuid4

 import anyio
@@ -38,7 +38,7 @@
     PREFECT_TASK_DEFAULT_RETRIES,
     temporary_settings,
 )
-from prefect.states import Running, State
+from prefect.states import Completed, Running, State
 from prefect.task_engine import (
     AsyncTaskRunEngine,
     SyncTaskRunEngine,
@@ -78,6 +78,32 @@ async def test_client_attr_returns_client_after_starting(self):
         with pytest.raises(RuntimeError, match="not started"):
             engine.client

+    async def test_set_task_run_state(self):
+        engine = SyncTaskRunEngine(task=foo)
+        with engine.initialize_run():
+            running_state = Running()
+
+            new_state = engine.set_state(running_state)
+            assert new_state == running_state
+
+            completed_state = Completed()
+            new_state = engine.set_state(completed_state)
+            assert new_state == completed_state
+
+    async def test_set_task_run_state_duplicated_timestamp(self):
+        engine = SyncTaskRunEngine(task=foo)
+        with engine.initialize_run():
+            running_state = Running()
+            completed_state = Completed()
+            completed_state.timestamp = running_state.timestamp
+
+            new_state = engine.set_state(running_state)
+            assert new_state == running_state
+
+            new_state = engine.set_state(completed_state)
+            assert new_state == completed_state
+            assert new_state.timestamp > running_state.timestamp
+

 class TestAsyncTaskRunEngine:
     async def test_basic_init(self):
@@ -100,6 +126,32 @@ async def test_client_attr_returns_client_after_starting(self):
         with pytest.raises(RuntimeError, match="not started"):
             engine.client

+    async def test_set_task_run_state(self):
+        engine = AsyncTaskRunEngine(task=foo)
+        async with engine.initialize_run():
+            running_state = Running()
+
+            new_state = await engine.set_state(running_state)
+            assert new_state == running_state
+
+            completed_state = Completed()
+            new_state = await engine.set_state(completed_state)
+            assert new_state == completed_state
+
+    async def test_set_task_run_state_duplicated_timestamp(self):
+        engine = AsyncTaskRunEngine(task=foo)
+        async with engine.initialize_run():
+            running_state = Running()
+            completed_state = Completed()
+            completed_state.timestamp = running_state.timestamp
+
+            new_state = await engine.set_state(running_state)
+            assert new_state == running_state
+
+            new_state = await engine.set_state(completed_state)
+            assert new_state == completed_state
+            assert new_state.timestamp > running_state.timestamp
+

 class TestRunTask:
     def test_run_task_with_client_provided_uuid(
@@ -519,6 +571,56 @@ async def second():
         assert one == 42
         assert two == 42

+    async def test_task_run_states(
+        self,
+        prefect_client,
+        events_pipeline,
+    ):
+        @task
+        async def foo():
+            return TaskRunContext.get().task_run.id
+
+        task_run_id = await run_task_async(foo)
+        await events_pipeline.process_events()
+        states = await prefect_client.read_task_run_states(task_run_id)
+
+        state_names = [state.name for state in states]
+        assert state_names == [
+            "Pending",
+            "Running",
+            "Completed",
+        ]
+
+    async def test_task_run_states_with_equal_timestamps(
+        self,
+        prefect_client,
+        events_pipeline,
+    ):
+        @task
+        async def foo():
+            return TaskRunContext.get().task_run.id
+
+        original_set_state = AsyncTaskRunEngine.set_state
+
+        async def alter_new_state_timestamp(engine, state, force=False):
+            """Give the new state the same timestamp as the current state."""
+            state.timestamp = engine.task_run.state.timestamp
+            return await original_set_state(engine, state, force=force)
+
+        with patch.object(AsyncTaskRunEngine, "set_state", alter_new_state_timestamp):
+            task_run_id = await run_task_async(foo)
+
+            await events_pipeline.process_events()
+            states = await prefect_client.read_task_run_states(task_run_id)
+
+            state_names = [state.name for state in states]
+            assert state_names == [
+                "Pending",
+                "Running",
+                "Completed",
+            ]
+            assert states[0].timestamp < states[1].timestamp < states[2].timestamp
+

 class TestTaskRunsSync:
     def test_basic(self):
@@ -729,6 +831,56 @@ def second():
         assert one == 42
         assert two == 42

+    async def test_task_run_states(
+        self,
+        prefect_client,
+        events_pipeline,
+    ):
+        @task
+        def foo():
+            return TaskRunContext.get().task_run.id
+
+        task_run_id = run_task_sync(foo)
+        await events_pipeline.process_events()
+        states = await prefect_client.read_task_run_states(task_run_id)
+
+        state_names = [state.name for state in states]
+        assert state_names == [
+            "Pending",
+            "Running",
+            "Completed",
+        ]
+
+    async def test_task_run_states_with_equal_timestamps(
+        self,
+        prefect_client,
+        events_pipeline,
+    ):
+        @task
+        def foo():
+            return TaskRunContext.get().task_run.id
+
+        original_set_state = SyncTaskRunEngine.set_state
+
+        def alter_new_state_timestamp(engine, state, force=False):
+            """Give the new state the same timestamp as the current state."""
+            state.timestamp = engine.task_run.state.timestamp
+            return original_set_state(engine, state, force=force)
+
+        with patch.object(SyncTaskRunEngine, "set_state", alter_new_state_timestamp):
+            task_run_id = run_task_sync(foo)
+
+            await events_pipeline.process_events()
+            states = await prefect_client.read_task_run_states(task_run_id)
+
+            state_names = [state.name for state in states]
+            assert state_names == [
+                "Pending",
+                "Running",
+                "Completed",
+            ]
+            assert states[0].timestamp < states[1].timestamp < states[2].timestamp
+

 class TestReturnState:
     async def test_return_state(self, prefect_client):

Async tasks stays in Pending state until Completed on Windows Client
### Bug summary

This issue is directly related to #15607 . After few hours of trial and error I managed to bring more light to it, at least i hope so.

**My findings indicate that Running state had the same timestamp as Pending state.**

Could there be an issue with `datetime.datetime.now()` (`pendulum.now()`) having lower resolution on Windows (5-15ms)?

More precisely, time between emitting task run pending state in [AsyncTaskRunEngine.initialize_run](https://github.com/PrefectHQ/prefect/blob/main/src/prefect/task_engine.py#L1176) and emitting running state in following [AsyncTaskRunEngine.begin_run](https://github.com/PrefectHQ/prefect/blob/main/src/prefect/task_engine.py#L904) is less than 15ms resulting in equal timestamp.

For Sync tasks this issue does not appear, probably due to wrapping `Task.create_local_run` in `run_coro_as_sync` [here](https://github.com/PrefectHQ/prefect/blob/main/src/prefect/task_engine.py#L660) adding overhead and increasing time between Pending and Running state.

My current setup is Prefect Server with PostgreSQL in docker and a client on Windows 10 machine. Client on Ubuntu 22 is not causing any problems.

The following code should reproduce the duplicate key value violation on a database. **Notice the submission of async tasks**, as for sync tasks the issue wont trigger.

```python
from prefect import flow, task

@flow
def test_flow():
    tasks = [test_task.submit() for i in range(4)]
    [task.wait() for task in tasks]
    return [task.state for task in tasks]

@task
async def test_task():
    subtasks = [test_subtask.submit() for _ in range(10)]
    [task.wait() for task in subtasks]
    return [task.state for task in subtasks]

@task
async def test_subtask():
    return 42

if __name__ == "__main__":
    test_flow()

```

The partial log from database about the violation:
```
2024-11-25 00:14:31.474 UTC [82] ERROR:  23505: duplicate key value violates unique constraint "uq_task_run_state__task_run_id_timestamp_desc"
2024-11-25 00:14:31.474 UTC [82] DETAIL:  Key (task_run_id, "timestamp")=(a230f4b0-8e76-4540-8698-349b60c1f47b, 2024-11-25 00:14:30.432075+00) already exists.
2024-11-25 00:14:31.474 UTC [82] CONTEXT:  unnamed portal with parameters: $1 = 'a230f4b0-8e76-4540-8698-349b60c1f47b', $2 = 'RUNNING', $3 = '2024-11-25 00:14:30.432075+00', $4 = 'Running', $5 = '', $6 = '{"deferred": false, "cache_key": null, "pause_key": null, "retriable": null, "flow_run_id": "e75cf6e7-3318-42cb-9f0a-a5fc32eee727", "task_run_id": "a230f4b0-8e76-4540-8698-349b60c1f47b", "pause_timeout": null, "refresh_cache": null, "transition_id": null, "scheduled_time": null, "cache_expiration": null, "pause_reschedule": false, "run_input_keyset": null, "child_flow_run_id": null, "task_parameters_id": null, "untrackable_result": false}', $7 = 'null', $8 = '3a8232be-baaa-4e83-9c6f-3a1b75e436bc', $9 = '2024-11-25 00:14:31.472665+00', $10 = '2024-11-25 00:14:31.47371+00'
2024-11-25 00:14:31.474 UTC [82] LOCATION:  _bt_check_unique, nbtinsert.c:666
2024-11-25 00:14:31.474 UTC [82] STATEMENT:  INSERT INTO task_run_state (task_run_id, type, timestamp, name, message, state_details, data, id, created, updated) VALUES ($1::UUID, $2::state_type, $3::TIMESTAMP WITH TIME ZONE, $4::VARCHAR, $5::VARCHAR, $6, $7::JSON, $8::UUID, $9::TIMESTAMP WITH TIME ZONE, $10::TIMESTAMP WITH TIME ZONE) ON CONFLICT (id) DO NOTHING

```

State table list for the task run:
```sql
SELECT * FROM task_run_state WHERE task_run_id = 'a230f4b0-8e76-4540-8698-349b60c1f47b';
                  id                  |            created            |            updated            |   type    |           timestamp           |   name    | message |                                                                                state_details                                                                                |                                                                                data                                                                                |             task_run_id              | result_artifact_id
--------------------------------------+-------------------------------+-------------------------------+-----------+-------------------------------+-----------+---------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------------------------------------+--------------------
 4436d06c-f6a2-486f-87b7-b13e399ba3e3 | 2024-11-25 00:14:31.432305+00 | 2024-11-25 00:14:31.433352+00 | PENDING   | 2024-11-25 00:14:30.432075+00 | Pending   |         | {"deferred": false, "cache_key": null, "pause_key": null, "retriable": null, "flow_run_id": "e75cf6e7-3318-42cb-9f0a-a5fc32eee727", "task_run_id": "a230f4b0-8e76-4540-8698-349b60c1f47b", "pause_timeout": null, "refresh_cache": null, "transition_id": null, "scheduled_time": null, "cache_expiration": null, "pause_reschedule": false, "run_input_keyset": null, "child_flow_run_id": null, "task_parameters_id": null, "untrackable_result": false} | null                                                                                | a230f4b0-8e76-4540-8698-349b60c1f47b |
 99bbc758-4685-4774-9e54-d5e126ec5188 | 2024-11-25 00:14:31.601826+00 | 2024-11-25 00:14:31.602852+00 | COMPLETED | 2024-11-25 00:14:30.463352+00 | Completed |         | {"deferred": false, "cache_key": null, "pause_key": null, "retriable": null, "flow_run_id": "e75cf6e7-3318-42cb-9f0a-a5fc32eee727", "task_run_id": "a230f4b0-8e76-4540-8698-349b60c1f47b", "pause_timeout": null, "refresh_cache": null, "transition_id": null, "scheduled_time": null, "cache_expiration": null, "pause_reschedule": false, "run_input_keyset": null, "child_flow_run_id": null, "task_parameters_id": null, "untrackable_result": true}  | {"expiration": null, "serializer": {"type": "pickle"}, "storage_key": "*****", "prefect_version": "3.1.4", "storage_block_id": null} | a230f4b0-8e76-4540-8698-349b60c1f47b |
(2 rows)

```

### Version info

```Text
Version:             3.1.4
API version:         0.8.4
Python version:      3.11.7
Git commit:          78ee41cb
Built:               Wed, Nov 20, 2024 7:37 PM
OS/Arch:             win32/AMD64
Profile:             ephemeral
Server type:         server
Pydantic version:    2.10.1
Integrations:
  prefect-dask:      0.3.2
  prefect-docker:    0.6.2
  prefect-gitlab:    0.3.1
```

### Additional context

PREFECT_DEBUG_MODE=True wont trigger the error as well as running the flow in debugger (due to the slower execution?).


diff --git a/src/prefect/flows.py b/src/prefect/flows.py
index 27dc6e7c4a8a..cc4f781b6499 100644
--- a/src/prefect/flows.py
+++ b/src/prefect/flows.py
@@ -10,6 +10,7 @@
 import importlib.util
 import inspect
 import os
+import sys
 import tempfile
 import warnings
 from functools import partial, update_wrapper
@@ -137,6 +138,16 @@
 if TYPE_CHECKING:
     from prefect.deployments.runner import FlexibleScheduleList, RunnerDeployment

+# Handle Python 3.8 compatibility for GenericAlias
+if sys.version_info >= (3, 9):
+    from types import GenericAlias  # novermin
+
+    GENERIC_ALIAS = (GenericAlias,)
+else:
+    from typing import _GenericAlias
+
+    GENERIC_ALIAS = (_GenericAlias,)
+

 @PrefectObjectRegistry.register_instances
 class Flow(Generic[P, R]):
@@ -530,18 +541,22 @@ def validate_parameters(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
                 is_v1_type(param.annotation) for param in sig.parameters.values()
             )
             has_v1_models = any(
-                issubclass(param.annotation, V1BaseModel)
-                if isinstance(param.annotation, type)
-                else False
+                (
+                    isinstance(param.annotation, type)
+                    and not isinstance(param.annotation, GENERIC_ALIAS)
+                    and issubclass(param.annotation, V1BaseModel)
+                )
                 for param in sig.parameters.values()
             )
             has_v2_types = any(
                 is_v2_type(param.annotation) for param in sig.parameters.values()
             )
             has_v2_models = any(
-                issubclass(param.annotation, V2BaseModel)
-                if isinstance(param.annotation, type)
-                else False
+                (
+                    isinstance(param.annotation, type)
+                    and not isinstance(param.annotation, GENERIC_ALIAS)
+                    and issubclass(param.annotation, V2BaseModel)
+                )
                 for param in sig.parameters.values()
             )

@@ -1601,7 +1616,9 @@ def flow(

 def select_flow(
-    flows: Iterable[Flow], flow_name: str = None, from_message: str = None
+    flows: Iterable[Flow],
+    flow_name: Optional[str] = None,
+    from_message: Optional[str] = None,
 ) -> Flow:
     """
     Select the only flow in an iterable or a flow specified by name.

diff --git a/tests/test_flows.py b/tests/test_flows.py
index 47bfea76ec4a..097018f97563 100644
--- a/tests/test_flows.py
+++ b/tests/test_flows.py
@@ -1517,6 +1517,23 @@ def my_flow(secret: SecretStr):
             "secret": SecretStr("my secret")
         }

+    @pytest.mark.skipif(
+        sys.version_info < (3, 9), reason="Python 3.9+ required for GenericAlias"
+    )
+    def test_flow_signature_can_contain_generic_type_hints(self):
+        """Test that generic type hints like dict[str, str] work correctly
+
+        this is a regression test for https://github.com/PrefectHQ/prefect/issues/16105
+        """
+
+        @flow
+        def my_flow(param: dict[str, str]):  # novermin
+            return param
+
+        test_data = {"foo": "bar"}
+        assert my_flow(test_data) == test_data
+        assert my_flow.validate_parameters({"param": test_data}) == {"param": test_data}
+

 class TestSubflowTaskInputs:
     async def test_subflow_with_one_upstream_task_future(self, prefect_client):

flow.validate_parameters regression
### Bug summary

Certain flow deployments that worked fine before cannot be executed anymore after updating from Prefect 2.20.9 to any newer Prefect version.
This is due to a change in the parameter validation.

MRE:
```python
from prefect import flow

@flow
def my_flow(param: dict[str, str]):
    pass

flow_run_params = {
    "param": {'foo': 'bar'}
}

my_flow.validate_parameters(flow_run_params)
```
runs fine for Prefect <= 2.20.9.

But raises
```python
Traceback (most recent call last):
  File "mre.py", line 13, in <module>
    my_flow.validate_parameters(flow_run_params)
  File "mre.py", line 532, in validate_parameters
    has_v1_models = any(
  File "mre.py", line 533, in <genexpr>
    issubclass(param.annotation, V1BaseModel)
  File "/usr/lib/python3.10/abc.py", line 123, in __subclasscheck__
    return _abc_subclasscheck(cls, subclass)
TypeError: issubclass() arg 1 must be a class
```
for Prefect >= 2.20.10

### Version info

```Text
Version:             2.20.14
API version:         0.8.4
Python version:      3.10.12
Git commit:          fb919c67
Built:               Mon, Nov 18, 2024 4:41 PM
OS/Arch:             linux/x86_64
Server type:         cloud
```

### Additional context

related to https://github.com/PrefectHQ/prefect/pull/15608

hi @j-tr - thanks for the issue!

can you share what version of `pydantic` you are using? I cannot seem to directly reproduce this
```python
In [1]: from prefect import flow
   ...:
   ...: @flow
   ...: def my_flow(param: dict[str, str]):
   ...:     pass
   ...:
   ...: flow_run_params = {
   ...:     "param": {'foo': 'bar'}
   ...: }
   ...:
   ...: my_flow.validate_parameters(flow_run_params)

Out[1]: {'param': {'foo': 'bar'}}

In [2]:

In [2]: !uv pip list | rg 'prefect|pydantic'
prefect                             2.20.14                           /Users/nate/github.com/prefecthq/prefect
pydantic                            2.10.2
pydantic-core                       2.27.1
```

---

**EDIT**: interesting, ok I reproduce it on python 3.10...