diff --git a/LICENSE b/LICENSE index ffdd12aad2..f2eb9930b5 100644 --- a/LICENSE +++ b/LICENSE @@ -233,3 +233,12 @@ Home page: https://hive.apache.org/ License: https://www.apache.org/licenses/LICENSE-2.0 -------------------------------------------------------------------------------- + +This product includes code from Conda. + +* Uses deprecations.py file that's part of conda project in conda/conda/ +* Uses test_deprecations.py file that's part of conda project in conda/tests/ + +Copyright: 2012 Anaconda, Inc. +Home page: https://conda.io/ +License: https://opensource.org/licenses/BSD-3-Clause diff --git a/mkdocs/docs/contributing.md b/mkdocs/docs/contributing.md index ebaeb74cec..5c9e8260a8 100644 --- a/mkdocs/docs/contributing.md +++ b/mkdocs/docs/contributing.md @@ -159,43 +159,137 @@ Below are the formalized conventions that we adhere to in the PyIceberg project. It is important to keep the Python public API compatible across versions. The Python official [PEP-8](https://peps.python.org/pep-0008/) defines public methods as: _Public attributes should have no leading underscores_. This means not removing any methods without any notice, or removing or renaming any existing parameters. Adding new optional parameters is okay. -If you want to remove a method, please add a deprecation notice by annotating the function using `@deprecated`: +### Functions, Methods, Properties, and Classes + +To deprecate functions, methods, or properties, use the `@deprecated` decorator. This indicates that the feature will be removed in a future release and notifies users about the deprecation timeline. + +#### Basic Example ```python -from pyiceberg.utils.deprecated import deprecated +from pyiceberg.utils._deprecations import deprecated + +@deprecated("1.5.0", "2.0.0") +def old_function(): + pass +``` + +This will warn: + +```text +Call to old_function, deprecated in 1.5.0, will be removed in 2.0.0. +``` + +#### Adding a Recommendation + +Optionally, include a recommendation to guide users toward an alternative feature: + +```python +from pyiceberg.utils._deprecations import deprecated + +@deprecated("1.5.0", "2.0.0", addendum="Use `new_function` instead.") +def old_function(): + pass +``` + +This will warn: + +```text +Call to old_function, deprecated in 1.5.0, will be removed in 2.0.0. Use `new_function` instead. +``` + +### Keyword Arguments + +To deprecate or rename keyword arguments, use the `@deprecated.argument` decorator. + +#### Deprecating a Keyword Argument + +```python +from pyiceberg.utils._deprecations import deprecated + +@deprecated.argument("1.5.0", "2.0.0", "old_arg") +def my_function(*, old_arg=True): + pass +``` + +This will warn: +```text +Call to my_function(old_arg), deprecated in 1.5.0, will be removed in 2.0.0. +``` + +#### Renaming a Keyword Argument + +```python +from pyiceberg.utils._deprecations import deprecated -@deprecated( - deprecated_in="0.1.0", - removed_in="0.2.0", - help_message="Please use load_something_else() instead", -) -def load_something(): +@deprecated.argument("1.5.0", "2.0.0", "old_arg", rename="new_arg") +def my_function(*, new_arg=True): pass ``` -Which will warn: +This will warn: ```text -Call to load_something, deprecated in 0.1.0, will be removed in 0.2.0. Please use load_something_else() instead. +Call to my_function(old_arg), deprecated in 1.5.0, will be removed in 2.0.0. Use `new_arg` instead. ``` -If you want to remove a property or notify about a behavior change, please add a deprecation notice by calling the deprecation_message function: +### Constants and Enums + +To deprecate constants or enums, use the `deprecated.constant` function. + +#### Deprecating a Constant ```python -from pyiceberg.utils.deprecated import deprecation_message +from pyiceberg.utils._deprecations import deprecated + +deprecated.constant("1.5.0", "2.0.0", "OLD_CONSTANT", 42) +``` + +This will warn: + +```text +OLD_CONSTANT, deprecated in 1.5.0, will be removed in 2.0.0. +``` + +#### Deprecating an Enum + +```python +from enum import Enum +from pyiceberg.utils._deprecations import deprecated + +class MyEnum(Enum): + OLD_VALUE = 42 + +deprecated.constant("1.5.0", "2.0.0", "MyEnum", MyEnum) +del MyEnum +``` + +This will warn: + +```text +MyEnum, deprecated in 1.5.0, will be removed in 2.0.0. +``` + +### Topics + +For deprecations that do not fit into the above categories, use the `deprecated.topic` function. + +```python +from pyiceberg.utils._deprecations import deprecated + +def some_function(): + # some logic + + if condition: + deprecated.topic("1.5.0", "2.0.0", topic="The ") -deprecation_message( - deprecated_in="0.1.0", - removed_in="0.2.0", - help_message="The old_property is deprecated. Please use the something_else property instead.", -) + # more logic ``` -Which will warn: +This will warn: ```text -Deprecated in 0.1.0, will be removed in 0.2.0. The old_property is deprecated. Please use the something_else property instead. +The , deprecated in 1.5.0, will be removed in 2.0.0. ``` ## Type annotations diff --git a/mkdocs/docs/how-to-release.md b/mkdocs/docs/how-to-release.md index f79f18ca82..af5399f1a4 100644 --- a/mkdocs/docs/how-to-release.md +++ b/mkdocs/docs/how-to-release.md @@ -32,21 +32,13 @@ For example, the API with the following deprecation tag should be removed when p ```python @deprecated( - deprecated_in="0.1.0", - removed_in="0.2.0", + deprecate_in="0.1.0", + remove_in="0.2.0", help_message="Please use load_something_else() instead", ) ``` -We also have the `deprecation_message` function. We need to change the behavior according to what is noted in the message of that deprecation. - -```python -deprecation_message( - deprecated_in="0.1.0", - removed_in="0.2.0", - help_message="The old_property is deprecated. Please use the something_else property instead.", -) -``` +We also have other deprecation strategies, so we recommend identifying all code references using the deprecation module (`pyiceberg.utils._deprecations`) and removing them before making a release. ## Running a release candidate diff --git a/pyiceberg/catalog/__init__.py b/pyiceberg/catalog/__init__.py index b189b4094d..1c3346ea4f 100644 --- a/pyiceberg/catalog/__init__.py +++ b/pyiceberg/catalog/__init__.py @@ -69,8 +69,8 @@ Properties, RecursiveDict, ) +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.config import Config, merge_config -from pyiceberg.utils.deprecated import deprecated, deprecation_message if TYPE_CHECKING: import pyarrow as pa @@ -631,9 +631,9 @@ def drop_view(self, identifier: Union[str, Identifier]) -> None: """ @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="Support for parsing catalog level identifier in Catalog identifiers is deprecated. Please refer to the table using only its namespace and its table name.", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Please refer to the table using only its namespace and its table name.", ) def identifier_to_tuple_without_catalog(self, identifier: Union[str, Identifier]) -> Identifier: """Convert an identifier to a tuple and drop this catalog's name from the first element. @@ -660,10 +660,11 @@ def _identifier_to_tuple_without_catalog(self, identifier: Union[str, Identifier """ identifier_tuple = Catalog.identifier_to_tuple(identifier) if len(identifier_tuple) >= 3 and identifier_tuple[0] == self.name: - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="Support for parsing catalog level identifier in Catalog identifiers is deprecated. Please refer to the table using only its namespace and its table name.", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix="Support for parsing catalog level identifier in Catalog identifiers", + topic="Please refer to the table using only its namespace and its table name.", ) identifier_tuple = identifier_tuple[1:] return identifier_tuple @@ -782,10 +783,11 @@ def __init__(self, name: str, **properties: str): super().__init__(name, **properties) if self.properties.get(DEPRECATED_BOTOCORE_SESSION): - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message=f"The property {DEPRECATED_BOTOCORE_SESSION} is deprecated and will be removed.", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix=f"The property {DEPRECATED_BOTOCORE_SESSION}", + topic="and will be removed.", ) def create_table_transaction( diff --git a/pyiceberg/catalog/rest.py b/pyiceberg/catalog/rest.py index e2584921ea..a5879de111 100644 --- a/pyiceberg/catalog/rest.py +++ b/pyiceberg/catalog/rest.py @@ -78,7 +78,7 @@ ) from pyiceberg.typedef import EMPTY_DICT, UTF8, IcebergBaseModel, Identifier, Properties from pyiceberg.types import transform_dict_value_to_str -from pyiceberg.utils.deprecated import deprecation_message +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.properties import get_first_property_value, property_as_bool if TYPE_CHECKING: @@ -318,10 +318,11 @@ def url(self, endpoint: str, prefixed: bool = True, **kwargs: Any) -> str: @property def auth_url(self) -> str: if self.properties.get(AUTH_URL): - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message=f"The property {AUTH_URL} is deprecated. Please use {OAUTH2_SERVER_URI} instead", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix=f"The property {AUTH_URL}", + topic=f"Please use {OAUTH2_SERVER_URI} instead", ) self._warn_oauth_tokens_deprecation() @@ -338,10 +339,11 @@ def _warn_oauth_tokens_deprecation(self) -> None: has_sigv4_enabled = property_as_bool(self.properties, SIGV4, False) if not has_oauth_server_uri and (has_init_token or has_credential) and not has_sigv4_enabled: - deprecation_message( - deprecated_in="0.8.0", - removed_in="1.0.0", - help_message="Iceberg REST client is missing the OAuth2 server URI " + deprecated.topic( + deprecate_in="0.8.0", + remove_in="1.0.0", + prefix="Default OAuth2 endpoint", + topic="Iceberg REST client is missing the OAuth2 server URI " f"configuration and defaults to {self.uri}{Endpoints.get_token}. " "This automatic fallback will be removed in a future Iceberg release." f"It is recommended to configure the OAuth2 endpoint using the '{OAUTH2_SERVER_URI}'" diff --git a/pyiceberg/cli/console.py b/pyiceberg/cli/console.py index 82c27a256b..de8b34f351 100644 --- a/pyiceberg/cli/console.py +++ b/pyiceberg/cli/console.py @@ -34,32 +34,24 @@ from pyiceberg.exceptions import NoSuchNamespaceError, NoSuchPropertyException, NoSuchTableError from pyiceberg.table import TableProperties from pyiceberg.table.refs import SnapshotRef -from pyiceberg.utils.deprecated import deprecated +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.properties import property_as_int +deprecated.constant( + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Use TableProperties.MAX_SNAPSHOT_AGE_MS_DEFAULT instead.", + constant="DEFAULT_MAX_SNAPSHOT_AGE_MS", + value=TableProperties.MAX_SNAPSHOT_AGE_MS_DEFAULT, +) -class DeprecatedConstants: - @property - @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="DEFAULT_MAX_SNAPSHOT_AGE_MS is deprecated. Use TableProperties.MAX_SNAPSHOT_AGE_MS_DEFAULT instead.", - ) - def DEFAULT_MAX_SNAPSHOT_AGE_MS(self) -> int: - return 432000000 - - @property - @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="DEFAULT_MIN_SNAPSHOTS_TO_KEEP is deprecated. Use TableProperties.MIN_SNAPSHOTS_TO_KEEP_DEFAULT instead.", - ) - def DEFAULT_MIN_SNAPSHOTS_TO_KEEP(self) -> int: - return 1 - - -DEFAULT_MIN_SNAPSHOTS_TO_KEEP = DeprecatedConstants().DEFAULT_MIN_SNAPSHOTS_TO_KEEP -DEFAULT_MAX_SNAPSHOT_AGE_MS = DeprecatedConstants().DEFAULT_MAX_SNAPSHOT_AGE_MS +deprecated.constant( + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Use TableProperties.MIN_SNAPSHOTS_TO_KEEP_DEFAULT instead.", + constant="DEFAULT_MIN_SNAPSHOTS_TO_KEEP", + value=TableProperties.MIN_SNAPSHOTS_TO_KEEP_DEFAULT, +) def catch_exception() -> Callable: # type: ignore diff --git a/pyiceberg/expressions/parser.py b/pyiceberg/expressions/parser.py index dcd8dceb2c..ab45a9c097 100644 --- a/pyiceberg/expressions/parser.py +++ b/pyiceberg/expressions/parser.py @@ -66,7 +66,7 @@ ) from pyiceberg.typedef import L from pyiceberg.types import strtobool -from pyiceberg.utils.deprecated import deprecation_message +from pyiceberg.utils._deprecations import deprecated ParserElement.enablePackrat() @@ -90,10 +90,11 @@ @column.set_parse_action def _(result: ParseResults) -> Reference: if len(result.column) > 1: - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="Parsing expressions with table name is deprecated. Only provide field names in the row_filter.", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix="Parsing expressions with table name", + topic="Only provide field names in the row_filter.", ) # TODO: Once this is removed, we will no longer take just the last index of parsed column result # And introduce support for parsing filter expressions with nested fields. diff --git a/pyiceberg/io/fsspec.py b/pyiceberg/io/fsspec.py index 434ae67df0..a43dc54963 100644 --- a/pyiceberg/io/fsspec.py +++ b/pyiceberg/io/fsspec.py @@ -87,7 +87,7 @@ OutputStream, ) from pyiceberg.typedef import Properties -from pyiceberg.utils.deprecated import deprecation_message +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.properties import get_first_property_value, property_as_bool logger = logging.getLogger(__name__) @@ -173,10 +173,11 @@ def _gs(properties: Properties) -> AbstractFileSystem: from gcsfs import GCSFileSystem if properties.get(GCS_ENDPOINT): - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message=f"The property {GCS_ENDPOINT} is deprecated, please use {GCS_SERVICE_HOST} instead", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix=f"The property {GCS_ENDPOINT}", + topic=f"please use {GCS_SERVICE_HOST} instead", ) return GCSFileSystem( project=properties.get(GCS_PROJECT_ID), @@ -197,10 +198,11 @@ def _adls(properties: Properties) -> AbstractFileSystem: for property_name in properties: if property_name.startswith(ADLFS_PREFIX): - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message=f"The property {property_name} is deprecated. Please use properties that start with adls.", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix=f"The property {property_name}", + topic="Please use properties that start with adls.", ) return AzureBlobFileSystem( diff --git a/pyiceberg/io/pyarrow.py b/pyiceberg/io/pyarrow.py index d2c4a6016e..7429f5b6a9 100644 --- a/pyiceberg/io/pyarrow.py +++ b/pyiceberg/io/pyarrow.py @@ -161,10 +161,10 @@ TimeType, UUIDType, ) +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.concurrent import ExecutorFactory from pyiceberg.utils.config import Config from pyiceberg.utils.datetime import millis_to_datetime -from pyiceberg.utils.deprecated import deprecated, deprecation_message from pyiceberg.utils.properties import get_first_property_value, property_as_bool, property_as_int from pyiceberg.utils.singleton import Singleton from pyiceberg.utils.truncate import truncate_upper_bound_binary_string, truncate_upper_bound_text_string @@ -403,10 +403,11 @@ def _initialize_fs(self, scheme: str, netloc: Optional[str] = None) -> FileSyste gcs_kwargs["default_bucket_location"] = bucket_location if endpoint := get_first_property_value(self.properties, GCS_SERVICE_HOST, GCS_ENDPOINT): if self.properties.get(GCS_ENDPOINT): - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message=f"The property {GCS_ENDPOINT} is deprecated, please use {GCS_SERVICE_HOST} instead", + deprecated.topic( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix=f"The property {GCS_ENDPOINT}", + topic=f"please use {GCS_SERVICE_HOST} instead", ) url_parts = urlparse(endpoint) gcs_kwargs["scheme"] = url_parts.scheme @@ -1525,9 +1526,9 @@ def _record_batches_from_scan_tasks_and_deletes( @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="project_table is deprecated. Use ArrowScan.to_table instead.", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Use ArrowScan.to_table instead.", ) def project_table( tasks: Iterable[FileScanTask], @@ -1624,9 +1625,9 @@ def project_table( @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="project_table is deprecated. Use ArrowScan.to_record_batches instead.", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Use ArrowScan.to_record_batches instead.", ) def project_batches( tasks: Iterable[FileScanTask], diff --git a/pyiceberg/table/__init__.py b/pyiceberg/table/__init__.py index 3eb74eee1f..da2351c03d 100644 --- a/pyiceberg/table/__init__.py +++ b/pyiceberg/table/__init__.py @@ -130,9 +130,9 @@ from pyiceberg.types import ( strtobool, ) +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.concurrent import ExecutorFactory from pyiceberg.utils.config import Config -from pyiceberg.utils.deprecated import deprecated, deprecation_message from pyiceberg.utils.properties import property_as_bool if TYPE_CHECKING: @@ -782,17 +782,17 @@ def refresh(self) -> Table: return self @property + @deprecated( + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Please use Table.name() function instead.", + ) def identifier(self) -> Identifier: """Return the identifier of this table. Returns: An Identifier tuple of the table name """ - deprecation_message( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="Table.identifier property is deprecated. Please use Table.name() function instead.", - ) return (self.catalog.name,) + self._identifier def name(self) -> Identifier: @@ -1538,54 +1538,54 @@ def _parquet_files_to_data_files(table_metadata: TableMetadata, file_paths: List @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="pyiceberg.table.Move has been changed to private class pyiceberg.table.update.schema._Move", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="pyiceberg.table.Move has been changed to private class pyiceberg.table.update.schema._Move", ) def Move(*args: Any, **kwargs: Any) -> _Move: return _Move(*args, **kwargs) @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="pyiceberg.table.MoveOperation has been changed to private class pyiceberg.table.update.schema._MoveOperation", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="pyiceberg.table.MoveOperation has been changed to private class pyiceberg.table.update.schema._MoveOperation", ) def MoveOperation(*args: Any, **kwargs: Any) -> _MoveOperation: return _MoveOperation(*args, **kwargs) @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="pyiceberg.table.DeleteFiles has been changed to private class pyiceberg.table.update.snapshot._DeleteFiles", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="pyiceberg.table.DeleteFiles has been changed to private class pyiceberg.table.update.snapshot._DeleteFiles", ) def DeleteFiles(*args: Any, **kwargs: Any) -> _DeleteFiles: return _DeleteFiles(*args, **kwargs) @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="pyiceberg.table.FastAppendFiles has been changed to private class pyiceberg.table.update.snapshot._FastAppendFiles", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="pyiceberg.table.FastAppendFiles has been changed to private class pyiceberg.table.update.snapshot._FastAppendFiles", ) def FastAppendFiles(*args: Any, **kwargs: Any) -> _FastAppendFiles: return _FastAppendFiles(*args, **kwargs) @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="pyiceberg.table.MergeAppendFiles has been changed to private class pyiceberg.table.update.snapshot._MergeAppendFiles", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="pyiceberg.table.MergeAppendFiles has been changed to private class pyiceberg.table.update.snapshot._MergeAppendFiles", ) def MergeAppendFiles(*args: Any, **kwargs: Any) -> _MergeAppendFiles: return _MergeAppendFiles(*args, **kwargs) @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="pyiceberg.table.OverwriteFiles has been changed to private class pyiceberg.table.update.snapshot._OverwriteFiles", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="pyiceberg.table.OverwriteFiles has been changed to private class pyiceberg.table.update.snapshot._OverwriteFiles", ) def OverwriteFiles(*args: Any, **kwargs: Any) -> _OverwriteFiles: return _OverwriteFiles(*args, **kwargs) diff --git a/pyiceberg/table/name_mapping.py b/pyiceberg/table/name_mapping.py index eaf5fc855d..563c06eb53 100644 --- a/pyiceberg/table/name_mapping.py +++ b/pyiceberg/table/name_mapping.py @@ -33,7 +33,7 @@ from pyiceberg.schema import P, PartnerAccessor, Schema, SchemaVisitor, SchemaWithPartnerVisitor, visit, visit_with_partner from pyiceberg.typedef import IcebergBaseModel, IcebergRootModel from pyiceberg.types import IcebergType, ListType, MapType, NestedField, PrimitiveType, StructType -from pyiceberg.utils.deprecated import deprecated +from pyiceberg.utils._deprecations import deprecated class MappedField(IcebergBaseModel): @@ -76,9 +76,9 @@ def _field_by_name(self) -> Dict[str, MappedField]: return visit_name_mapping(self, _IndexByName()) @deprecated( - deprecated_in="0.8.0", - removed_in="0.9.0", - help_message="Please use `apply_name_mapping` instead", + deprecate_in="0.8.0", + remove_in="0.9.0", + topic="Please use `apply_name_mapping` instead", ) def find(self, *names: str) -> MappedField: name = ".".join(names) diff --git a/pyiceberg/table/update/__init__.py b/pyiceberg/table/update/__init__.py index b81a2bf7f4..925f0558c6 100644 --- a/pyiceberg/table/update/__init__.py +++ b/pyiceberg/table/update/__init__.py @@ -43,8 +43,8 @@ from pyiceberg.types import ( transform_dict_value_to_str, ) +from pyiceberg.utils._deprecations import deprecated from pyiceberg.utils.datetime import datetime_to_millis -from pyiceberg.utils.deprecated import deprecation_notice from pyiceberg.utils.properties import property_as_int if TYPE_CHECKING: @@ -93,8 +93,11 @@ class AddSchemaUpdate(IcebergBaseModel): initial_change: bool = Field( default=False, exclude=True, - deprecated=deprecation_notice( - deprecated_in="0.8.0", removed_in="0.9.0", help_message="CreateTableTransaction can work without this field" + deprecated=deprecated.message( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix="initial_change", + topic="CreateTableTransaction can work without this field", ), ) @@ -113,8 +116,11 @@ class AddPartitionSpecUpdate(IcebergBaseModel): initial_change: bool = Field( default=False, exclude=True, - deprecated=deprecation_notice( - deprecated_in="0.8.0", removed_in="0.9.0", help_message="CreateTableTransaction can work without this field" + deprecated=deprecated.message( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix="initial_change", + topic="CreateTableTransaction can work without this field", ), ) @@ -133,8 +139,11 @@ class AddSortOrderUpdate(IcebergBaseModel): initial_change: bool = Field( default=False, exclude=True, - deprecated=deprecation_notice( - deprecated_in="0.8.0", removed_in="0.9.0", help_message="CreateTableTransaction can work without this field" + deprecated=deprecated.message( + deprecate_in="0.8.0", + remove_in="0.9.0", + prefix="initial_change", + topic="CreateTableTransaction can work without this field", ), ) diff --git a/pyiceberg/utils/_deprecations.py b/pyiceberg/utils/_deprecations.py new file mode 100644 index 0000000000..56dfa83205 --- /dev/null +++ b/pyiceberg/utils/_deprecations.py @@ -0,0 +1,465 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# This file is originally licensed under the BSD 3-Clause +# License by Anaconda, Inc. +# +# For details, see the LICENSE file and the following URL: +# https://opensource.org/licenses/BSD-3-Clause +# +# SPDX-License-Identifier: BSD-3-Clause +# +# Copyright (C) 2012 Anaconda, Inc + +"""Tools to aid in deprecating code.""" + +from __future__ import annotations + +import sys +import warnings +from argparse import Action +from functools import wraps +from types import ModuleType +from typing import TYPE_CHECKING + +from packaging.version import InvalidVersion, Version + +if TYPE_CHECKING: + from argparse import ArgumentParser, Namespace + from typing import Any, Callable, ParamSpec, Self, TypeVar + + T = TypeVar("T") + P = ParamSpec("P") + + ActionType = TypeVar("ActionType", bound=type[Action]) + +from pyiceberg import __version__ + + +class DeprecatedError(RuntimeError): + pass + + +# inspired by deprecation (https://deprecation.readthedocs.io/en/latest/) and +# CPython's warnings._deprecated +class DeprecationHandler: + _version: str | None + _version_tuple: tuple[int, ...] | None + _version_object: Version | None + + def __init__(self: Self, version: str) -> None: + """Create a deprecation handle for the specified version. + + :param version: The version to compare against when checking deprecation statuses. + """ + self._version = Version(version) + # Try to parse the version string as a simple tuple[int, ...] to avoid + # packaging.version import and costlier version comparisons. + self._version_tuple = self._get_version_tuple(version) + self._version_object = None + + @staticmethod + def _get_version_tuple(version: str) -> tuple[int, ...] | None: + """Return version as non-empty tuple of ints if possible, else None. + + :param version: Version string to parse. + """ + try: + return tuple(int(part) for part in version.strip().split(".")) or None + except (AttributeError, ValueError): + return None + + def _version_less_than(self, version: str) -> bool: + """Compare the current version with the provided version string.""" + try: + target_version = Version(version) + except InvalidVersion as e: + raise ValueError(f"Invalid semantic version: {version}") from e + + return self._version < target_version + + def __call__( + self: Self, + deprecate_in: str, + remove_in: str, + *, + topic: str | None = None, + stack: int = 0, + deprecation_type: type[Warning] = DeprecationWarning, + ) -> Callable[[Callable[P, T]], Callable[P, T]]: + """Deprecate decorator for functions, methods, & classes. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param topic: Optional additional messaging. Useful to indicate what to do instead. + :param stack: Optional stacklevel increment. + """ + + def deprecated_decorator(func: Callable[P, T]) -> Callable[P, T]: + # detect function name and generate message + category, message = self._generate_message( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=f"{func.__module__}.{func.__qualname__}", + topic=topic, + deprecation_type=deprecation_type, + ) + + # alert developer that it's time to remove something + if not category: + raise DeprecatedError(message) + + # alert user that it's time to remove something + @wraps(func) + def inner(*args: P.args, **kwargs: P.kwargs) -> T: + with warnings.catch_warnings(): # temporarily override warning handling + warnings.simplefilter("always", category) # turn off filter + + warnings.warn(message, category, stacklevel=2 + stack) + + return func(*args, **kwargs) + + return inner + + return deprecated_decorator + + def argument( + self: Self, + deprecate_in: str, + remove_in: str, + argument: str, + *, + rename: str | None = None, + topic: str | None = None, + stack: int = 0, + deprecation_type: type[Warning] = DeprecationWarning, + ) -> Callable[[Callable[P, T]], Callable[P, T]]: + """Deprecate decorator for keyword arguments. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param argument: The argument to deprecate. + :param rename: Optional new argument name. + :param topic: Optional additional messaging. Useful to indicate what to do instead. + :param stack: Optional stacklevel increment. + """ + + def deprecated_decorator(func: Callable[P, T]) -> Callable[P, T]: + # detect function name and generate message + category, message = self._generate_message( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=f"{func.__module__}.{func.__qualname__}({argument})", + # provide a default topic if renaming and no topic is provided + topic=(f"Use '{rename}' instead." if rename and not topic else topic), + deprecation_type=deprecation_type, + ) + + # alert developer that it's time to remove something + if not category: + raise DeprecatedError(message) + + # alert user that it's time to remove something + @wraps(func) + def inner(*args: P.args, **kwargs: P.kwargs) -> T: + # only warn about argument deprecations if the argument is used + if argument in kwargs: + with warnings.catch_warnings(): # temporarily override warning handling + warnings.simplefilter("always", category) # turn off filter + + warnings.warn(message, category, stacklevel=2 + stack) + + # rename argument deprecations as needed + value = kwargs.pop(argument, None) + if rename: + kwargs.setdefault(rename, value) + + return func(*args, **kwargs) + + return inner + + return deprecated_decorator + + def action( + self: Self, + deprecate_in: str, + remove_in: str, + action: ActionType, + *, + topic: str | None = None, + stack: int = 0, + deprecation_type: type[Warning] = FutureWarning, + ) -> ActionType: + """Wrap any argparse.Action to issue a deprecation warning.""" + + class DeprecationMixin(Action): + category: type[Warning] + help: str # override argparse.Action's help type annotation + + def __init__(inner_self: Self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + category, message = self._generate_message( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=( + # option_string are ordered shortest to longest, + # use the longest as it's the most descriptive + f"`{inner_self.option_strings[-1]}`" + if inner_self.option_strings + # if not a flag/switch, use the destination itself + else f"`{inner_self.dest}`" + ), + topic=topic, + deprecation_type=deprecation_type, + ) + + # alert developer that it's time to remove something + if not category: + raise DeprecatedError(message) + + inner_self.category = category + inner_self.help = message + + def __call__( + inner_self: Self, + parser: ArgumentParser, + namespace: Namespace, + values: Any, + option_string: str | None = None, + ) -> None: + # alert user that it's time to remove something + if values: + with warnings.catch_warnings(): # temporarily override warning handling + warnings.simplefilter("always", inner_self.category) # turn off filter + + warnings.warn( + inner_self.help, + inner_self.category, + stacklevel=7 + stack, + ) + + super().__call__(parser, namespace, values, option_string) + + return type(action.__name__, (DeprecationMixin, action), {}) # type: ignore[return-value] + + def module( + self: Self, + deprecate_in: str, + remove_in: str, + *, + topic: str | None = None, + stack: int = 0, + ) -> None: + """Deprecate function for modules. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param topic: Optional additional messaging. Useful to indicate what to do instead. + :param stack: Optional stacklevel increment. + """ + self.topic( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=self._get_module(stack)[1], + topic=topic, + stack=2 + stack, + ) + + def constant( + self: Self, + deprecate_in: str, + remove_in: str, + constant: str, + value: Any, + *, + topic: str | None = None, + stack: int = 0, + deprecation_type: type[Warning] = DeprecationWarning, + ) -> None: + """Deprecate function for module constant/global. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param constant: + :param value: + :param topic: Optional additional messaging. Useful to indicate what to do instead. + :param stack: Optional stacklevel increment. + """ + # detect calling module + module, fullname = self._get_module(stack) + # detect function name and generate message + category, message = self._generate_message( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=f"{fullname}.{constant}", + topic=topic, + deprecation_type=deprecation_type, + ) + + # alert developer that it's time to remove something + if not category: + raise DeprecatedError(message) + + # patch module level __getattr__ to alert user that it's time to remove something + super_getattr = getattr(module, "__getattr__", None) + + def __getattr__(name: str) -> Any: + if name == constant: + with warnings.catch_warnings(): # temporarily override warning handling + warnings.simplefilter("always", category) # turn off filter + + warnings.warn(message, category, stacklevel=3 + stack) + return value + + if super_getattr: + return super_getattr(name) + + raise AttributeError(f"module '{fullname}' has no attribute '{name}'") + + module.__getattr__ = __getattr__ # type: ignore[method-assign] + + def topic( + self: Self, + deprecate_in: str, + remove_in: str, + *, + topic: str | None = None, + prefix: str | None = None, + stack: int = 0, + deprecation_type: type[Warning] = DeprecationWarning, + ) -> None: + """Deprecate function for a topic. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param prefix: The topic being deprecated. + :param topic: Optional additional messaging. Useful to indicate what to do instead. + :param stack: Optional stacklevel increment. + """ + # detect function name and generate message + category, message = self._generate_message( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=prefix, + topic=topic, + deprecation_type=deprecation_type, + ) + + # alert developer that it's time to remove something + if not category: + raise DeprecatedError(message) + + # alert user that it's time to remove something + with warnings.catch_warnings(): # temporarily override warning handling + warnings.simplefilter("always", category) # turn off filter + + warnings.warn(message, category, stacklevel=2 + stack) + + def _get_module(self: Self, stack: int) -> tuple[ModuleType, str]: + """Detect the module from which we are being called. + + :param stack: The stacklevel increment. + :return: The module and module name. + """ + try: + frame = sys._getframe(2 + stack) + except IndexError: + # IndexError: 2 + stack is out of range + pass + else: + # Shortcut finding the module by manually inspecting loaded modules. + try: + filename = frame.f_code.co_filename + except AttributeError: + # AttributeError: frame.f_code.co_filename is undefined + pass + else: + # use a copy of sys.modules to avoid RuntimeError during iteration + # see https://github.com/conda/conda/issues/13754 + for loaded in tuple(sys.modules.values()): + if not hasattr(loaded, "__file__"): + continue + if loaded.__file__ == filename: + return (loaded, loaded.__name__) + + # If above failed, do an expensive import and costly getmodule call. + import inspect + + module = inspect.getmodule(frame) + if module is not None: + return (module, module.__name__) + + raise DeprecatedError("unable to determine the calling module") + + def message( + self: Self, + deprecate_in: str, + remove_in: str, + prefix: str, + topic: str, + ) -> str: + """Generate a deprecation message. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param topic: The topic being deprecated. + """ + _, message = self._generate_message( + deprecate_in=deprecate_in, + remove_in=remove_in, + prefix=prefix, + topic=topic, + ) + + return message + + def _generate_message( + self: Self, + deprecate_in: str, + remove_in: str, + prefix: str | None, + topic: str | None, + deprecation_type: type[Warning] | None = None, + ) -> tuple[type[Warning] | None, str]: + """Generate the standardized deprecation message. + + Determine whether the deprecation is pending, active, or past. + + :param deprecate_in: Version in which code will be marked as deprecated. + :param remove_in: Version in which code is expected to be removed. + :param prefix: The message prefix, usually the function name. + :param topic: Additional messaging. Useful to indicate what to do instead. + :param deprecation_type: The warning type to use for active deprecations. + :return: The warning category (if applicable) and the message. + """ + category: type[Warning] | None + + if self._version_less_than(deprecate_in): + category = PendingDeprecationWarning + warning = f"is pending deprecation and will be removed in {remove_in}." + elif self._version_less_than(remove_in): + category = deprecation_type or DeprecationWarning + warning = f"is deprecated and will be removed in {remove_in}." + else: + category = None + warning = f"was slated for removal in {remove_in}." + + return category, " ".join(filter(None, [prefix, warning, topic])) + + +deprecated = DeprecationHandler(__version__) diff --git a/pyiceberg/utils/deprecated.py b/pyiceberg/utils/deprecated.py deleted file mode 100644 index da2cb3b500..0000000000 --- a/pyiceberg/utils/deprecated.py +++ /dev/null @@ -1,64 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -import functools -import warnings -from typing import Any, Callable, Optional - - -def deprecated(deprecated_in: str, removed_in: str, help_message: Optional[str] = None) -> Callable: # type: ignore - """Mark functions as deprecated. - - Adding this will result in a warning being emitted when the function is used. - """ - if help_message is not None: - help_message = f" {help_message}." - - def decorator(func: Callable): # type: ignore - @functools.wraps(func) - def new_func(*args: Any, **kwargs: Any) -> Any: - message = f"Call to {func.__name__}, deprecated in {deprecated_in}, will be removed in {removed_in}.{help_message}" - - _deprecation_warning(message) - - return func(*args, **kwargs) - - return new_func - - return decorator - - -def deprecation_notice(deprecated_in: str, removed_in: str, help_message: Optional[str]) -> str: - """Return a deprecation notice.""" - return f"Deprecated in {deprecated_in}, will be removed in {removed_in}. {help_message}" - - -def deprecation_message(deprecated_in: str, removed_in: str, help_message: Optional[str]) -> None: - """Mark properties or behaviors as deprecated. - - Adding this will result in a warning being emitted. - """ - _deprecation_warning(deprecation_notice(deprecated_in, removed_in, help_message)) - - -def _deprecation_warning(message: str) -> None: - with warnings.catch_warnings(): # temporarily override warning handling - warnings.simplefilter("always", DeprecationWarning) # turn off filter - warnings.warn( - message, - category=DeprecationWarning, - stacklevel=2, - ) diff --git a/tests/utils/test_deprecated.py b/tests/utils/test_deprecated.py deleted file mode 100644 index a77ed66563..0000000000 --- a/tests/utils/test_deprecated.py +++ /dev/null @@ -1,51 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -from unittest.mock import Mock, patch - -from pyiceberg.utils.deprecated import deprecated - - -@patch("warnings.warn") -def test_deprecated(warn: Mock) -> None: - @deprecated( - deprecated_in="0.1.0", - removed_in="0.2.0", - help_message="Please use load_something_else() instead", - ) - def deprecated_method() -> None: - pass - - deprecated_method() - - assert warn.called - assert warn.call_args[0] == ( - "Call to deprecated_method, deprecated in 0.1.0, will be removed in 0.2.0. Please use load_something_else() instead.", - ) - - -@patch("warnings.warn") -def test_deprecation_message(warn: Mock) -> None: - from pyiceberg.utils.deprecated import deprecation_message - - deprecation_message( - deprecated_in="0.1.0", - removed_in="0.2.0", - help_message="Please use something_else instead", - ) - - assert warn.called - assert warn.call_args[0] == ("Deprecated in 0.1.0, will be removed in 0.2.0. Please use something_else instead",) diff --git a/tests/utils/test_deprecations.py b/tests/utils/test_deprecations.py new file mode 100644 index 0000000000..85cd8edf13 --- /dev/null +++ b/tests/utils/test_deprecations.py @@ -0,0 +1,229 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# This file is originally licensed under the BSD 3-Clause +# License by Anaconda, Inc. +# +# For details, see the LICENSE file and the following URL: +# https://opensource.org/licenses/BSD-3-Clause +# +# SPDX-License-Identifier: BSD-3-Clause +# +# Copyright (C) 2012 Anaconda, Inc + +from __future__ import annotations + +import sys +from argparse import ArgumentParser, _StoreAction, _StoreTrueAction +from contextlib import nullcontext +from typing import TYPE_CHECKING + +import pytest + +from pyiceberg.utils._deprecations import DeprecatedError, DeprecationHandler + +if TYPE_CHECKING: + pass + +PENDING = pytest.param( + DeprecationHandler("1.0"), # deprecated + PendingDeprecationWarning, # warning + "pending deprecation", # message + id="pending", +) +FUTURE = pytest.param( + DeprecationHandler("2.0"), # deprecated + FutureWarning, # warning + "deprecated", # message + id="future", +) +DEPRECATED = pytest.param( + DeprecationHandler("2.0"), # deprecated + DeprecationWarning, # warning + "deprecated", # message + id="deprecated", +) +REMOVE = pytest.param( + DeprecationHandler("3.0"), # deprecated + None, # warning + None, # message + id="remove", +) + +parametrize_user = pytest.mark.parametrize( + "deprecated,warning,message", + [PENDING, FUTURE, REMOVE], +) +parametrize_dev = pytest.mark.parametrize( + "deprecated,warning,message", + [PENDING, DEPRECATED, REMOVE], +) + + +@parametrize_dev +def test_function( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Calling a deprecated function displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + + @deprecated("2.0", "3.0") + def foo() -> bool: + return True + + with pytest.warns(warning, match=message): + assert foo() + + +@parametrize_dev +def test_method( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Calling a deprecated method displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + + class Bar: + @deprecated("2.0", "3.0") + def foo(self) -> bool: + return True + + with pytest.warns(warning, match=message): + assert Bar().foo() + + +@parametrize_dev +def test_class( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Calling a deprecated class displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + + @deprecated("2.0", "3.0") + class Foo: + pass + + with pytest.warns(warning, match=message): + assert Foo() + + +@parametrize_dev +def test_arguments( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Calling a deprecated argument displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + + @deprecated.argument("2.0", "3.0", "three") + def foo(_one: int, _two: int) -> bool: + return True + + # too many arguments, can only deprecate keyword arguments + with pytest.raises(TypeError): + assert foo(1, 2, 3) # type: ignore[call-arg] + + # alerting user to pending deprecation + with pytest.warns(warning, match=message): + assert foo(1, 2, three=3) # type: ignore[call-arg] + + # normal usage not needing deprecation + assert foo(1, 2) + + +@parametrize_user +def test_action( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Calling a deprecated argparse.Action displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + parser = ArgumentParser() + parser.add_argument( + "--foo", + action=deprecated.action("2.0", "3.0", _StoreTrueAction), + ) + parser.add_argument( + "bar", + action=deprecated.action("2.0", "3.0", _StoreAction), + ) + + with pytest.warns(warning, match=message): + parser.parse_args(["--foo", "some_value"]) + + with pytest.warns(warning, match=message): + parser.parse_args(["bar"]) + + +@parametrize_dev +def test_module( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Importing a deprecated module displays associated warning (or error).""" + with pytest.warns(warning, match=message) if warning else pytest.raises(DeprecatedError): + deprecated.module("2.0", "3.0") + + +@parametrize_dev +def test_constant( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Using a deprecated constant displays associated warning (or error).""" + with nullcontext() if warning else pytest.raises(DeprecatedError): + deprecated.constant("2.0", "3.0", "SOME_CONSTANT", 42) + module = sys.modules[__name__] + + with pytest.warns(warning, match=message): + module.SOME_CONSTANT # noqa: B018 + + +@parametrize_dev +def test_topic( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str | None, +) -> None: + """Reaching a deprecated topic displays associated warning (or error).""" + with pytest.warns(warning, match=message) if warning else pytest.raises(DeprecatedError): + deprecated.topic("2.0", "3.0", topic="Some special topic") + + +@parametrize_dev +def test_message( + deprecated: DeprecationHandler, + warning: DeprecationWarning | None, + message: str, +) -> None: + expected = { + "pending deprecation": "some function is pending deprecation and will be removed in 3.0. more context", + "deprecated": "some function is deprecated and will be removed in 3.0. more context", + } + if warning: + assert deprecated.message("2.0", "3.0", "some function", "more context") == expected[message] + else: + pytest.raises(DeprecatedError)