diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f1a6ae47..52e7c9a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,28 +7,28 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' cache-dependency-path: 'requirements/*.txt' - name: Install dev dependencies run: python -m pip install -r requirements/dev.txt - name: Run linting - run: python -m tox -e lint + run: python -m tox -e lint,mypy,mypy-samples-image,mypy-samples-json test: strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.9', '3.10', '3.11', '3.12', '3.13'] os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} cache: 'pip' diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 56bbf66a..eeebb883 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -10,37 +10,38 @@ on: jobs: build_dist: name: Build source distribution - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Build SDist and wheel run: pipx run build - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: + name: artifact path: dist/* - name: Check metadata run: pipx run twine check dist/* publish: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest if: github.event_name == 'push' needs: [ build_dist ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" cache: 'pip' - name: Install build dependencies run: pip install -U setuptools wheel build - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: # unpacks default artifact into dist/ # if `name: artifact` is omitted, the action will create extra parent dir @@ -51,6 +52,7 @@ jobs: with: user: __token__ password: ${{ secrets.pypi_password }} + attestations: false - name: Install GitPython and cloudevents for pypi_packaging run: pip install -U -r requirements/publish.txt - name: Create Tag diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15ab6545..32fde356 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,22 +1,22 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-toml - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 6.0.1 hooks: - id: isort args: [ "--profile", "black", "--filter-files" ] - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 25.1.0 hooks: - id: black language_version: python3.11 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.16.0 hooks: - id: mypy files: ^(cloudevents/) @@ -24,4 +24,4 @@ repos: types: [ python ] args: [ ] additional_dependencies: - - "pydantic" + - "pydantic~=2.7" diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fcb0e5..47023884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.12.0] + +### Changed + +- Dropped Python3.8 support while it has reached EOL. ([]) + +## [1.11.1] + +### Fixed +- Kafka `conversion` marshaller and unmarshaller typings ([#240]) +- Improved public API type annotations and fixed unit test type errors ([#248]) + +## [1.11.0] + +### Fixed +- Pydantic v2 `examples` keyword usage and improved typings handling ([#235]) +- Kafka `to_binary` check for invalid `content-type` attribute ([#232]) + +### Changed + +- Dropped Python3.7 from CI while its EOL. ([#236]) + ## [1.10.1] ### Fixed @@ -190,6 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release +[1.11.0]: https://github.com/cloudevents/sdk-python/compare/1.10.1...1.11.0 [1.10.1]: https://github.com/cloudevents/sdk-python/compare/1.10.0...1.10.1 [1.10.0]: https://github.com/cloudevents/sdk-python/compare/1.9.0...1.10.0 [1.9.0]: https://github.com/cloudevents/sdk-python/compare/1.8.0...1.9.0 @@ -273,3 +296,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#219]: https://github.com/cloudevents/sdk-python/pull/219 [#221]: https://github.com/cloudevents/sdk-python/pull/221 [#229]: https://github.com/cloudevents/sdk-python/pull/229 +[#232]: https://github.com/cloudevents/sdk-python/pull/232 +[#235]: https://github.com/cloudevents/sdk-python/pull/235 +[#236]: https://github.com/cloudevents/sdk-python/pull/236 +[#240]: https://github.com/cloudevents/sdk-python/pull/240 +[#248]: https://github.com/cloudevents/sdk-python/pull/248 diff --git a/cloudevents/__init__.py b/cloudevents/__init__.py index c6e11514..e97372bc 100644 --- a/cloudevents/__init__.py +++ b/cloudevents/__init__.py @@ -12,4 +12,4 @@ # License for the specific language governing permissions and limitations # under the License. -__version__ = "1.10.1" +__version__ = "1.12.0" diff --git a/cloudevents/abstract/event.py b/cloudevents/abstract/event.py index c18ca34b..18c6df19 100644 --- a/cloudevents/abstract/event.py +++ b/cloudevents/abstract/event.py @@ -32,7 +32,7 @@ class CloudEvent: @classmethod def create( cls: typing.Type[AnyCloudEvent], - attributes: typing.Dict[str, typing.Any], + attributes: typing.Mapping[str, typing.Any], data: typing.Optional[typing.Any], ) -> AnyCloudEvent: """ diff --git a/cloudevents/conversion.py b/cloudevents/conversion.py index c73e3ed0..6b83cfe8 100644 --- a/cloudevents/conversion.py +++ b/cloudevents/conversion.py @@ -91,7 +91,9 @@ def from_json( def from_http( event_type: typing.Type[AnyCloudEvent], - headers: typing.Mapping[str, str], + headers: typing.Union[ + typing.Mapping[str, str], types.SupportsDuplicateItems[str, str] + ], data: typing.Optional[typing.Union[str, bytes]], data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> AnyCloudEvent: @@ -260,7 +262,7 @@ def best_effort_encode_attribute_value(value: typing.Any) -> typing.Any: def from_dict( event_type: typing.Type[AnyCloudEvent], - event: typing.Dict[str, typing.Any], + event: typing.Mapping[str, typing.Any], ) -> AnyCloudEvent: """ Constructs an Event object of a given `event_type` from diff --git a/cloudevents/http/conversion.py b/cloudevents/http/conversion.py index a7da926b..13955ea4 100644 --- a/cloudevents/http/conversion.py +++ b/cloudevents/http/conversion.py @@ -37,7 +37,9 @@ def from_json( def from_http( - headers: typing.Dict[str, str], + headers: typing.Union[ + typing.Mapping[str, str], types.SupportsDuplicateItems[str, str] + ], data: typing.Optional[typing.Union[str, bytes]], data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> CloudEvent: @@ -58,7 +60,7 @@ def from_http( def from_dict( - event: typing.Dict[str, typing.Any], + event: typing.Mapping[str, typing.Any], ) -> CloudEvent: """ Constructs a CloudEvent from a dict `event` representation. diff --git a/cloudevents/http/event.py b/cloudevents/http/event.py index c7a066d6..f3c00638 100644 --- a/cloudevents/http/event.py +++ b/cloudevents/http/event.py @@ -34,11 +34,13 @@ class CloudEvent(abstract.CloudEvent): @classmethod def create( - cls, attributes: typing.Dict[str, typing.Any], data: typing.Optional[typing.Any] + cls, + attributes: typing.Mapping[str, typing.Any], + data: typing.Optional[typing.Any], ) -> "CloudEvent": return cls(attributes, data) - def __init__(self, attributes: typing.Dict[str, str], data: typing.Any = None): + def __init__(self, attributes: typing.Mapping[str, str], data: typing.Any = None): """ Event Constructor :param attributes: a dict with cloudevent attributes. Minimally diff --git a/cloudevents/kafka/conversion.py b/cloudevents/kafka/conversion.py index 832594d1..bdf2acab 100644 --- a/cloudevents/kafka/conversion.py +++ b/cloudevents/kafka/conversion.py @@ -21,9 +21,14 @@ from cloudevents.kafka.exceptions import KeyMapperError from cloudevents.sdk import types -DEFAULT_MARSHALLER: types.MarshallerType = json.dumps -DEFAULT_UNMARSHALLER: types.MarshallerType = json.loads -DEFAULT_EMBEDDED_DATA_MARSHALLER: types.MarshallerType = lambda x: x +JSON_MARSHALLER: types.MarshallerType = json.dumps +JSON_UNMARSHALLER: types.UnmarshallerType = json.loads +IDENTITY_MARSHALLER = IDENTITY_UNMARSHALLER = lambda x: x + +DEFAULT_MARSHALLER: types.MarshallerType = JSON_MARSHALLER +DEFAULT_UNMARSHALLER: types.UnmarshallerType = JSON_UNMARSHALLER +DEFAULT_EMBEDDED_DATA_MARSHALLER: types.MarshallerType = IDENTITY_MARSHALLER +DEFAULT_EMBEDDED_DATA_UNMARSHALLER: types.UnmarshallerType = IDENTITY_UNMARSHALLER class KafkaMessage(typing.NamedTuple): @@ -87,10 +92,10 @@ def to_binary( ) headers = {} - if event["content-type"]: - headers["content-type"] = event["content-type"].encode("utf-8") + if event["datacontenttype"]: + headers["content-type"] = event["datacontenttype"].encode("utf-8") for attr, value in event.get_attributes().items(): - if attr not in ["data", "partitionkey", "content-type"]: + if attr not in ["data", "partitionkey", "datacontenttype"]: if value is not None: headers["ce_{0}".format(attr)] = value.encode("utf-8") @@ -106,11 +111,29 @@ def to_binary( return KafkaMessage(headers, message_key, data) +@typing.overload def from_binary( message: KafkaMessage, - event_type: typing.Optional[typing.Type[AnyCloudEvent]] = None, - data_unmarshaller: typing.Optional[types.MarshallerType] = None, + event_type: None = None, + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, +) -> http.CloudEvent: + pass + + +@typing.overload +def from_binary( + message: KafkaMessage, + event_type: typing.Type[AnyCloudEvent], + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> AnyCloudEvent: + pass + + +def from_binary( + message: KafkaMessage, + event_type: typing.Optional[typing.Type[AnyCloudEvent]] = None, + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, +) -> typing.Union[http.CloudEvent, AnyCloudEvent]: """ Returns a CloudEvent from a KafkaMessage in binary format. @@ -126,7 +149,7 @@ def from_binary( for header, value in message.headers.items(): header = header.lower() if header == "content-type": - attributes["content-type"] = value.decode() + attributes["datacontenttype"] = value.decode() elif header.startswith("ce_"): attributes[header[3:]] = value.decode() @@ -139,10 +162,11 @@ def from_binary( raise cloud_exceptions.DataUnmarshallerError( f"Failed to unmarshall data with error: {type(e).__name__}('{e}')" ) + result: typing.Union[http.CloudEvent, AnyCloudEvent] if event_type: result = event_type.create(attributes, data) else: - result = http.CloudEvent.create(attributes, data) # type: ignore + result = http.CloudEvent.create(attributes, data) return result @@ -189,8 +213,8 @@ def to_structured( attrs["data"] = data headers = {} - if "content-type" in attrs: - headers["content-type"] = attrs.pop("content-type").encode("utf-8") + if "datacontenttype" in attrs: + headers["content-type"] = attrs.pop("datacontenttype").encode("utf-8") try: value = envelope_marshaller(attrs) @@ -205,12 +229,32 @@ def to_structured( return KafkaMessage(headers, message_key, value) +@typing.overload def from_structured( message: KafkaMessage, - event_type: typing.Optional[typing.Type[AnyCloudEvent]] = None, - data_unmarshaller: typing.Optional[types.MarshallerType] = None, + event_type: None = None, + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, + envelope_unmarshaller: typing.Optional[types.UnmarshallerType] = None, +) -> http.CloudEvent: + pass + + +@typing.overload +def from_structured( + message: KafkaMessage, + event_type: typing.Type[AnyCloudEvent], + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, envelope_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> AnyCloudEvent: + pass + + +def from_structured( + message: KafkaMessage, + event_type: typing.Optional[typing.Type[AnyCloudEvent]] = None, + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, + envelope_unmarshaller: typing.Optional[types.UnmarshallerType] = None, +) -> typing.Union[http.CloudEvent, AnyCloudEvent]: """ Returns a CloudEvent from a KafkaMessage in structured format. @@ -222,7 +266,7 @@ def from_structured( :returns: CloudEvent """ - data_unmarshaller = data_unmarshaller or DEFAULT_EMBEDDED_DATA_MARSHALLER + data_unmarshaller = data_unmarshaller or DEFAULT_EMBEDDED_DATA_UNMARSHALLER envelope_unmarshaller = envelope_unmarshaller or DEFAULT_UNMARSHALLER try: structure = envelope_unmarshaller(message.value) @@ -255,9 +299,13 @@ def from_structured( attributes[name] = decoded_value for header, val in message.headers.items(): - attributes[header.lower()] = val.decode() + if header.lower() == "content-type": + attributes["datacontenttype"] = val.decode() + else: + attributes[header.lower()] = val.decode() + result: typing.Union[AnyCloudEvent, http.CloudEvent] if event_type: result = event_type.create(attributes, data) else: - result = http.CloudEvent.create(attributes, data) # type: ignore + result = http.CloudEvent.create(attributes, data) return result diff --git a/cloudevents/pydantic/__init__.py b/cloudevents/pydantic/__init__.py index 409eb441..f8556ca1 100644 --- a/cloudevents/pydantic/__init__.py +++ b/cloudevents/pydantic/__init__.py @@ -12,22 +12,31 @@ # License for the specific language governing permissions and limitations # under the License. +from typing import TYPE_CHECKING + from cloudevents.exceptions import PydanticFeatureNotInstalled try: - from pydantic import VERSION as PYDANTIC_VERSION - - pydantic_major_version = PYDANTIC_VERSION.split(".")[0] - if pydantic_major_version == "1": - from cloudevents.pydantic.v1 import CloudEvent, from_dict, from_http, from_json - + if TYPE_CHECKING: + from cloudevents.pydantic.v2 import CloudEvent, from_dict, from_http, from_json else: - from cloudevents.pydantic.v2 import ( # type: ignore - CloudEvent, - from_dict, - from_http, - from_json, - ) + from pydantic import VERSION as PYDANTIC_VERSION + + pydantic_major_version = PYDANTIC_VERSION.split(".")[0] + if pydantic_major_version == "1": + from cloudevents.pydantic.v1 import ( + CloudEvent, + from_dict, + from_http, + from_json, + ) + else: + from cloudevents.pydantic.v2 import ( + CloudEvent, + from_dict, + from_http, + from_json, + ) except ImportError: # pragma: no cover # hard to test raise PydanticFeatureNotInstalled( diff --git a/cloudevents/pydantic/v1/conversion.py b/cloudevents/pydantic/v1/conversion.py index dcf0b7db..9f03372e 100644 --- a/cloudevents/pydantic/v1/conversion.py +++ b/cloudevents/pydantic/v1/conversion.py @@ -21,7 +21,9 @@ def from_http( - headers: typing.Dict[str, str], + headers: typing.Union[ + typing.Mapping[str, str], types.SupportsDuplicateItems[str, str] + ], data: typing.Optional[typing.AnyStr], data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> CloudEvent: @@ -63,7 +65,7 @@ def from_json( def from_dict( - event: typing.Dict[str, typing.Any], + event: typing.Mapping[str, typing.Any], ) -> CloudEvent: """ Construct an CloudEvent from a dict `event` representation. diff --git a/cloudevents/pydantic/v1/event.py b/cloudevents/pydantic/v1/event.py index d18736a4..98c61364 100644 --- a/cloudevents/pydantic/v1/event.py +++ b/cloudevents/pydantic/v1/event.py @@ -100,7 +100,9 @@ class CloudEvent(abstract.CloudEvent, BaseModel): # type: ignore @classmethod def create( - cls, attributes: typing.Dict[str, typing.Any], data: typing.Optional[typing.Any] + cls, + attributes: typing.Mapping[str, typing.Any], + data: typing.Optional[typing.Any], ) -> "CloudEvent": return cls(attributes, data) @@ -155,7 +157,7 @@ def create( def __init__( # type: ignore[no-untyped-def] self, - attributes: typing.Optional[typing.Dict[str, typing.Any]] = None, + attributes: typing.Optional[typing.Mapping[str, typing.Any]] = None, data: typing.Optional[typing.Any] = None, **kwargs, ): diff --git a/cloudevents/pydantic/v2/conversion.py b/cloudevents/pydantic/v2/conversion.py index 65108544..1745a572 100644 --- a/cloudevents/pydantic/v2/conversion.py +++ b/cloudevents/pydantic/v2/conversion.py @@ -22,7 +22,9 @@ def from_http( - headers: typing.Dict[str, str], + headers: typing.Union[ + typing.Mapping[str, str], types.SupportsDuplicateItems[str, str] + ], data: typing.Optional[typing.AnyStr], data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> CloudEvent: @@ -64,7 +66,7 @@ def from_json( def from_dict( - event: typing.Dict[str, typing.Any], + event: typing.Mapping[str, typing.Any], ) -> CloudEvent: """ Construct an CloudEvent from a dict `event` representation. diff --git a/cloudevents/pydantic/v2/event.py b/cloudevents/pydantic/v2/event.py index 4ae8bb5c..34a9b659 100644 --- a/cloudevents/pydantic/v2/event.py +++ b/cloudevents/pydantic/v2/event.py @@ -44,66 +44,68 @@ class CloudEvent(abstract.CloudEvent, BaseModel): # type: ignore @classmethod def create( - cls, attributes: typing.Dict[str, typing.Any], data: typing.Optional[typing.Any] + cls, + attributes: typing.Mapping[str, typing.Any], + data: typing.Optional[typing.Any], ) -> "CloudEvent": return cls(attributes, data) data: typing.Optional[typing.Any] = Field( title=FIELD_DESCRIPTIONS["data"].get("title"), description=FIELD_DESCRIPTIONS["data"].get("description"), - example=FIELD_DESCRIPTIONS["data"].get("example"), + examples=[FIELD_DESCRIPTIONS["data"].get("example")], default=None, ) source: str = Field( title=FIELD_DESCRIPTIONS["source"].get("title"), description=FIELD_DESCRIPTIONS["source"].get("description"), - example=FIELD_DESCRIPTIONS["source"].get("example"), + examples=[FIELD_DESCRIPTIONS["source"].get("example")], ) id: str = Field( title=FIELD_DESCRIPTIONS["id"].get("title"), description=FIELD_DESCRIPTIONS["id"].get("description"), - example=FIELD_DESCRIPTIONS["id"].get("example"), + examples=[FIELD_DESCRIPTIONS["id"].get("example")], default_factory=attribute.default_id_selection_algorithm, ) type: str = Field( title=FIELD_DESCRIPTIONS["type"].get("title"), description=FIELD_DESCRIPTIONS["type"].get("description"), - example=FIELD_DESCRIPTIONS["type"].get("example"), + examples=[FIELD_DESCRIPTIONS["type"].get("example")], ) specversion: attribute.SpecVersion = Field( title=FIELD_DESCRIPTIONS["specversion"].get("title"), description=FIELD_DESCRIPTIONS["specversion"].get("description"), - example=FIELD_DESCRIPTIONS["specversion"].get("example"), + examples=[FIELD_DESCRIPTIONS["specversion"].get("example")], default=attribute.DEFAULT_SPECVERSION, ) time: typing.Optional[datetime.datetime] = Field( title=FIELD_DESCRIPTIONS["time"].get("title"), description=FIELD_DESCRIPTIONS["time"].get("description"), - example=FIELD_DESCRIPTIONS["time"].get("example"), + examples=[FIELD_DESCRIPTIONS["time"].get("example")], default_factory=attribute.default_time_selection_algorithm, ) subject: typing.Optional[str] = Field( title=FIELD_DESCRIPTIONS["subject"].get("title"), description=FIELD_DESCRIPTIONS["subject"].get("description"), - example=FIELD_DESCRIPTIONS["subject"].get("example"), + examples=[FIELD_DESCRIPTIONS["subject"].get("example")], default=None, ) datacontenttype: typing.Optional[str] = Field( title=FIELD_DESCRIPTIONS["datacontenttype"].get("title"), description=FIELD_DESCRIPTIONS["datacontenttype"].get("description"), - example=FIELD_DESCRIPTIONS["datacontenttype"].get("example"), + examples=[FIELD_DESCRIPTIONS["datacontenttype"].get("example")], default=None, ) dataschema: typing.Optional[str] = Field( title=FIELD_DESCRIPTIONS["dataschema"].get("title"), description=FIELD_DESCRIPTIONS["dataschema"].get("description"), - example=FIELD_DESCRIPTIONS["dataschema"].get("example"), + examples=[FIELD_DESCRIPTIONS["dataschema"].get("example")], default=None, ) def __init__( # type: ignore[no-untyped-def] self, - attributes: typing.Optional[typing.Dict[str, typing.Any]] = None, + attributes: typing.Optional[typing.Mapping[str, typing.Any]] = None, data: typing.Optional[typing.Any] = None, **kwargs, ): @@ -173,6 +175,8 @@ def model_validate_json( *, strict: typing.Optional[bool] = None, context: typing.Optional[typing.Dict[str, Any]] = None, + by_alias: typing.Optional[bool] = None, + by_name: typing.Optional[bool] = None, ) -> "CloudEvent": return conversion.from_json(cls, json_data) diff --git a/cloudevents/sdk/event/v1.py b/cloudevents/sdk/event/v1.py index 18d1f3af..0f2e1d50 100644 --- a/cloudevents/sdk/event/v1.py +++ b/cloudevents/sdk/event/v1.py @@ -11,10 +11,15 @@ # 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 __future__ import annotations + import typing from cloudevents.sdk.event import base, opt +if typing.TYPE_CHECKING: + from typing_extensions import Self + class Event(base.BaseEvent): _ce_required_fields = {"id", "source", "type", "specversion"} @@ -79,39 +84,39 @@ def Extensions(self) -> dict: return {} return dict(result) - def SetEventType(self, eventType: str) -> base.BaseEvent: + def SetEventType(self, eventType: str) -> Self: self.Set("type", eventType) return self - def SetSource(self, source: str) -> base.BaseEvent: + def SetSource(self, source: str) -> Self: self.Set("source", source) return self - def SetEventID(self, eventID: str) -> base.BaseEvent: + def SetEventID(self, eventID: str) -> Self: self.Set("id", eventID) return self - def SetEventTime(self, eventTime: typing.Optional[str]) -> base.BaseEvent: + def SetEventTime(self, eventTime: typing.Optional[str]) -> Self: self.Set("time", eventTime) return self - def SetSubject(self, subject: typing.Optional[str]) -> base.BaseEvent: + def SetSubject(self, subject: typing.Optional[str]) -> Self: self.Set("subject", subject) return self - def SetSchema(self, schema: typing.Optional[str]) -> base.BaseEvent: + def SetSchema(self, schema: typing.Optional[str]) -> Self: self.Set("dataschema", schema) return self - def SetContentType(self, contentType: typing.Optional[str]) -> base.BaseEvent: + def SetContentType(self, contentType: typing.Optional[str]) -> Self: self.Set("datacontenttype", contentType) return self - def SetData(self, data: typing.Optional[object]) -> base.BaseEvent: + def SetData(self, data: typing.Optional[object]) -> Self: self.Set("data", data) return self - def SetExtensions(self, extensions: typing.Optional[dict]) -> base.BaseEvent: + def SetExtensions(self, extensions: typing.Optional[dict]) -> Self: self.Set("extensions", extensions) return self diff --git a/cloudevents/sdk/types.py b/cloudevents/sdk/types.py index e6ab46e4..6baef6b0 100644 --- a/cloudevents/sdk/types.py +++ b/cloudevents/sdk/types.py @@ -14,9 +14,25 @@ import typing +_K_co = typing.TypeVar("_K_co", covariant=True) +_V_co = typing.TypeVar("_V_co", covariant=True) + # Use consistent types for marshal and unmarshal functions across # both JSON and Binary format. MarshallerType = typing.Callable[[typing.Any], typing.AnyStr] UnmarshallerType = typing.Callable[[typing.AnyStr], typing.Any] + + +class SupportsDuplicateItems(typing.Protocol[_K_co, _V_co]): + """ + Dict-like objects with an items() method that may produce duplicate keys. + """ + + # This is wider than _typeshed.SupportsItems, which expects items() to + # return type an AbstractSet. werkzeug's Headers class satisfies this type, + # but not _typeshed.SupportsItems. + + def items(self) -> typing.Iterable[typing.Tuple[_K_co, _V_co]]: + pass diff --git a/cloudevents/tests/test_converters.py b/cloudevents/tests/test_converters.py index b91d6b39..50d783b5 100644 --- a/cloudevents/tests/test_converters.py +++ b/cloudevents/tests/test_converters.py @@ -21,7 +21,7 @@ def test_binary_converter_raise_unsupported(): with pytest.raises(exceptions.UnsupportedEvent): cnvtr = binary.BinaryHTTPCloudEventConverter() - cnvtr.read(None, {}, None, None) + cnvtr.read(None, {}, None, None) # type: ignore[arg-type] # intentionally wrong type # noqa: E501 def test_base_converters_raise_exceptions(): @@ -35,8 +35,8 @@ def test_base_converters_raise_exceptions(): with pytest.raises(Exception): cnvtr = base.Converter() - cnvtr.write(None, None) + cnvtr.write(None, None) # type: ignore[arg-type] # intentionally wrong type with pytest.raises(Exception): cnvtr = base.Converter() - cnvtr.read(None, None, None, None) + cnvtr.read(None, None, None, None) # type: ignore[arg-type] # intentionally wrong type # noqa: E501 diff --git a/cloudevents/tests/test_event_from_request_converter.py b/cloudevents/tests/test_event_from_request_converter.py index 901284bb..362b1cae 100644 --- a/cloudevents/tests/test_event_from_request_converter.py +++ b/cloudevents/tests/test_event_from_request_converter.py @@ -25,7 +25,7 @@ @pytest.mark.parametrize("event_class", [v03.Event, v1.Event]) def test_binary_converter_upstream(event_class): m = marshaller.NewHTTPMarshaller([binary.NewBinaryHTTPCloudEventConverter()]) - event = m.FromRequest(event_class(), data.headers[event_class], None, lambda x: x) + event = m.FromRequest(event_class(), data.headers[event_class], b"", lambda x: x) assert event is not None assert event.EventType() == data.ce_type assert event.EventID() == data.ce_id diff --git a/cloudevents/tests/test_event_pipeline.py b/cloudevents/tests/test_event_pipeline.py index efc79749..dae3dc2d 100644 --- a/cloudevents/tests/test_event_pipeline.py +++ b/cloudevents/tests/test_event_pipeline.py @@ -77,7 +77,7 @@ def test_object_event_v1(): _, structured_body = m.ToRequest(event) assert isinstance(structured_body, bytes) structured_obj = json.loads(structured_body) - error_msg = f"Body was {structured_body}, obj is {structured_obj}" + error_msg = f"Body was {structured_body!r}, obj is {structured_obj}" assert isinstance(structured_obj, dict), error_msg assert isinstance(structured_obj["data"], dict), error_msg assert len(structured_obj["data"]) == 1, error_msg diff --git a/cloudevents/tests/test_http_events.py b/cloudevents/tests/test_http_events.py index b21c3729..3d4c8d52 100644 --- a/cloudevents/tests/test_http_events.py +++ b/cloudevents/tests/test_http_events.py @@ -11,6 +11,7 @@ # 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 __future__ import annotations import bz2 import io @@ -241,11 +242,11 @@ def test_structured_to_request(specversion): assert headers["content-type"] == "application/cloudevents+json" for key in attributes: assert body[key] == attributes[key] - assert body["data"] == data, f"|{body_bytes}|| {body}" + assert body["data"] == data, f"|{body_bytes!r}|| {body}" @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_attributes_view_accessor(specversion: str): +def test_attributes_view_accessor(specversion: str) -> None: attributes: dict[str, typing.Any] = { "specversion": specversion, "type": "word.found.name", @@ -333,7 +334,7 @@ def test_valid_structured_events(specversion): events_queue = [] num_cloudevents = 30 for i in range(num_cloudevents): - event = { + raw_event = { "id": f"id{i}", "source": f"source{i}.com.test", "type": "cloudevent.test.type", @@ -343,7 +344,7 @@ def test_valid_structured_events(specversion): events_queue.append( from_http( {"content-type": "application/cloudevents+json"}, - json.dumps(event), + json.dumps(raw_event), ) ) @@ -454,7 +455,7 @@ def test_invalid_data_format_structured_from_http(): headers = {"Content-Type": "application/cloudevents+json"} data = 20 with pytest.raises(cloud_exceptions.InvalidStructuredJSON) as e: - from_http(headers, data) + from_http(headers, data) # type: ignore[arg-type] # intentionally wrong type assert "Expected json of type (str, bytes, bytearray)" in str(e.value) @@ -526,7 +527,7 @@ def test_generic_exception(): e.errisinstance(cloud_exceptions.MissingRequiredFields) with pytest.raises(cloud_exceptions.GenericException) as e: - from_http({}, 123) + from_http({}, 123) # type: ignore[arg-type] # intentionally wrong type e.errisinstance(cloud_exceptions.InvalidStructuredJSON) with pytest.raises(cloud_exceptions.GenericException) as e: diff --git a/cloudevents/tests/test_kafka_conversions.py b/cloudevents/tests/test_kafka_conversions.py index 696e75cb..584a05e4 100644 --- a/cloudevents/tests/test_kafka_conversions.py +++ b/cloudevents/tests/test_kafka_conversions.py @@ -19,6 +19,7 @@ import pytest from cloudevents import exceptions as cloud_exceptions +from cloudevents.abstract.event import AnyCloudEvent from cloudevents.http import CloudEvent from cloudevents.kafka.conversion import ( KafkaMessage, @@ -36,7 +37,9 @@ def simple_serialize(data: dict) -> bytes: def simple_deserialize(data: bytes) -> dict: - return json.loads(data.decode()) + value = json.loads(data.decode()) + assert isinstance(value, dict) + return value def failing_func(*args): @@ -47,7 +50,7 @@ class KafkaConversionTestBase: expected_data = {"name": "test", "amount": 1} expected_custom_mapped_key = "custom-key" - def custom_key_mapper(self, _) -> str: + def custom_key_mapper(self, _: AnyCloudEvent) -> str: return self.expected_custom_mapped_key @pytest.fixture @@ -59,7 +62,7 @@ def source_event(self) -> CloudEvent: "source": "pytest", "type": "com.pytest.test", "time": datetime.datetime(2000, 1, 1, 6, 42, 33).isoformat(), - "content-type": "foo", + "datacontenttype": "foo", "partitionkey": "test_key_123", }, data=self.expected_data, @@ -123,7 +126,7 @@ def test_sets_headers(self, source_event): assert result.headers["ce_source"] == source_event["source"].encode("utf-8") assert result.headers["ce_type"] == source_event["type"].encode("utf-8") assert result.headers["ce_time"] == source_event["time"].encode("utf-8") - assert result.headers["content-type"] == source_event["content-type"].encode( + assert result.headers["content-type"] == source_event["datacontenttype"].encode( "utf-8" ) assert "data" not in result.headers @@ -163,7 +166,7 @@ def source_binary_bytes_message(self) -> KafkaMessage: "ce_time": datetime.datetime(2000, 1, 1, 6, 42, 33) .isoformat() .encode("utf-8"), - "content-type": "foo".encode("utf-8"), + "datacontenttype": "foo".encode("utf-8"), }, value=simple_serialize(self.expected_data), key="test_key_123", @@ -205,7 +208,7 @@ def test_sets_attrs_from_headers(self, source_binary_json_message): assert result["type"] == source_binary_json_message.headers["ce_type"].decode() assert result["time"] == source_binary_json_message.headers["ce_time"].decode() assert ( - result["content-type"] + result["datacontenttype"] == source_binary_json_message.headers["content-type"].decode() ) @@ -328,7 +331,7 @@ def test_no_key(self, source_event): def test_sets_headers(self, source_event): result = to_structured(source_event) assert len(result.headers) == 1 - assert result.headers["content-type"] == source_event["content-type"].encode( + assert result.headers["content-type"] == source_event["datacontenttype"].encode( "utf-8" ) @@ -474,7 +477,7 @@ def test_sets_content_type_default_envelope_unmarshaller( ): result = from_structured(source_structured_json_message) assert ( - result["content-type"] + result["datacontenttype"] == source_structured_json_message.headers["content-type"].decode() ) @@ -487,7 +490,7 @@ def test_sets_content_type_custom_envelope_unmarshaller( envelope_unmarshaller=custom_unmarshaller, ) assert ( - result["content-type"] + result["datacontenttype"] == source_structured_bytes_bytes_message.headers["content-type"].decode() ) diff --git a/cloudevents/tests/test_marshaller.py b/cloudevents/tests/test_marshaller.py index 90609891..6561418b 100644 --- a/cloudevents/tests/test_marshaller.py +++ b/cloudevents/tests/test_marshaller.py @@ -50,14 +50,14 @@ def test_from_request_wrong_unmarshaller(): with pytest.raises(exceptions.InvalidDataUnmarshaller): m = marshaller.NewDefaultHTTPMarshaller() _ = m.FromRequest( - event=v1.Event(), headers={}, body="", data_unmarshaller=object() + event=v1.Event(), headers={}, body="", data_unmarshaller=object() # type: ignore[arg-type] # intentionally wrong type # noqa: E501 ) def test_to_request_wrong_marshaller(): with pytest.raises(exceptions.InvalidDataMarshaller): m = marshaller.NewDefaultHTTPMarshaller() - _ = m.ToRequest(v1.Event(), data_marshaller="") + _ = m.ToRequest(v1.Event(), data_marshaller="") # type: ignore[arg-type] # intentionally wrong type # noqa: E501 def test_from_request_cannot_read(binary_headers): diff --git a/cloudevents/tests/test_pydantic_events.py b/cloudevents/tests/test_pydantic_events.py index 3e536f05..30ad1fe3 100644 --- a/cloudevents/tests/test_pydantic_events.py +++ b/cloudevents/tests/test_pydantic_events.py @@ -11,6 +11,7 @@ # 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 __future__ import annotations import bz2 import io @@ -28,10 +29,13 @@ from cloudevents.pydantic.v1.event import CloudEvent as PydanticV1CloudEvent from cloudevents.pydantic.v2.conversion import from_http as pydantic_v2_from_http from cloudevents.pydantic.v2.event import CloudEvent as PydanticV2CloudEvent -from cloudevents.sdk import converters +from cloudevents.sdk import converters, types from cloudevents.sdk.converters.binary import is_binary from cloudevents.sdk.converters.structured import is_structured +if typing.TYPE_CHECKING: + from typing_extensions import TypeAlias + invalid_test_headers = [ { "ce-source": "", @@ -70,7 +74,30 @@ app = Sanic("test_pydantic_http_events") -_pydantic_implementation = { + +AnyPydanticCloudEvent: TypeAlias = typing.Union[ + PydanticV1CloudEvent, PydanticV2CloudEvent +] + + +class FromHttpFn(typing.Protocol): + def __call__( + self, + headers: typing.Dict[str, str], + data: typing.Optional[typing.AnyStr], + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, + ) -> AnyPydanticCloudEvent: + pass + + +class PydanticImplementation(typing.TypedDict): + event: typing.Type[AnyPydanticCloudEvent] + validation_error: typing.Type[Exception] + from_http: FromHttpFn + pydantic_version: typing.Literal["v1", "v2"] + + +_pydantic_implementation: typing.Mapping[str, PydanticImplementation] = { "v1": { "event": PydanticV1CloudEvent, "validation_error": PydanticV1ValidationError, @@ -87,7 +114,9 @@ @pytest.fixture(params=["v1", "v2"]) -def cloudevents_implementation(request): +def cloudevents_implementation( + request: pytest.FixtureRequest, +) -> PydanticImplementation: return _pydantic_implementation[request.param] @@ -108,7 +137,9 @@ async def echo(request, pydantic_version): @pytest.mark.parametrize("body", invalid_cloudevent_request_body) -def test_missing_required_fields_structured(body, cloudevents_implementation): +def test_missing_required_fields_structured( + body: dict, cloudevents_implementation: PydanticImplementation +) -> None: with pytest.raises(cloud_exceptions.MissingRequiredFields): _ = cloudevents_implementation["from_http"]( {"Content-Type": "application/cloudevents+json"}, json.dumps(body) @@ -116,20 +147,26 @@ def test_missing_required_fields_structured(body, cloudevents_implementation): @pytest.mark.parametrize("headers", invalid_test_headers) -def test_missing_required_fields_binary(headers, cloudevents_implementation): +def test_missing_required_fields_binary( + headers: dict, cloudevents_implementation: PydanticImplementation +) -> None: with pytest.raises(cloud_exceptions.MissingRequiredFields): _ = cloudevents_implementation["from_http"](headers, json.dumps(test_data)) @pytest.mark.parametrize("headers", invalid_test_headers) -def test_missing_required_fields_empty_data_binary(headers, cloudevents_implementation): +def test_missing_required_fields_empty_data_binary( + headers: dict, cloudevents_implementation: PydanticImplementation +) -> None: # Test for issue #115 with pytest.raises(cloud_exceptions.MissingRequiredFields): _ = cloudevents_implementation["from_http"](headers, None) @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_emit_binary_event(specversion, cloudevents_implementation): +def test_emit_binary_event( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: headers = { "ce-id": "my-id", "ce-source": "", @@ -159,7 +196,9 @@ def test_emit_binary_event(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_emit_structured_event(specversion, cloudevents_implementation): +def test_emit_structured_event( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: headers = {"Content-Type": "application/cloudevents+json"} body = { "id": "my-id", @@ -188,7 +227,11 @@ def test_emit_structured_event(specversion, cloudevents_implementation): "converter", [converters.TypeBinary, converters.TypeStructured] ) @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_roundtrip_non_json_event(converter, specversion, cloudevents_implementation): +def test_roundtrip_non_json_event( + converter: str, + specversion: str, + cloudevents_implementation: PydanticImplementation, +) -> None: input_data = io.BytesIO() for _ in range(100): for j in range(20): @@ -217,7 +260,9 @@ def test_roundtrip_non_json_event(converter, specversion, cloudevents_implementa @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_missing_ce_prefix_binary_event(specversion, cloudevents_implementation): +def test_missing_ce_prefix_binary_event( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: prefixed_headers = {} headers = { "ce-id": "my-id", @@ -240,9 +285,11 @@ def test_missing_ce_prefix_binary_event(specversion, cloudevents_implementation) @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_valid_binary_events(specversion, cloudevents_implementation): +def test_valid_binary_events( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: # Test creating multiple cloud events - events_queue = [] + events_queue: list[AnyPydanticCloudEvent] = [] headers = {} num_cloudevents = 30 for i in range(num_cloudevents): @@ -258,7 +305,7 @@ def test_valid_binary_events(specversion, cloudevents_implementation): ) for i, event in enumerate(events_queue): - data = event.data + assert isinstance(event.data, dict) assert event["id"] == f"id{i}" assert event["source"] == f"source{i}.com.test" assert event["specversion"] == specversion @@ -266,7 +313,9 @@ def test_valid_binary_events(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_structured_to_request(specversion, cloudevents_implementation): +def test_structured_to_request( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: attributes = { "specversion": specversion, "type": "word.found.name", @@ -283,11 +332,13 @@ def test_structured_to_request(specversion, cloudevents_implementation): assert headers["content-type"] == "application/cloudevents+json" for key in attributes: assert body[key] == attributes[key] - assert body["data"] == data, f"|{body_bytes}|| {body}" + assert body["data"] == data, f"|{body_bytes!r}|| {body}" @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_attributes_view_accessor(specversion: str, cloudevents_implementation): +def test_attributes_view_accessor( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: attributes: dict[str, typing.Any] = { "specversion": specversion, "type": "word.found.name", @@ -296,9 +347,7 @@ def test_attributes_view_accessor(specversion: str, cloudevents_implementation): } data = {"message": "Hello World!"} - event: cloudevents_implementation["event"] = cloudevents_implementation["event"]( - attributes, data - ) + event = cloudevents_implementation["event"](attributes, data) event_attributes: typing.Mapping[str, typing.Any] = event.get_attributes() assert event_attributes["specversion"] == attributes["specversion"] assert event_attributes["type"] == attributes["type"] @@ -308,7 +357,9 @@ def test_attributes_view_accessor(specversion: str, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_binary_to_request(specversion, cloudevents_implementation): +def test_binary_to_request( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: attributes = { "specversion": specversion, "type": "word.found.name", @@ -327,7 +378,9 @@ def test_binary_to_request(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_empty_data_structured_event(specversion, cloudevents_implementation): +def test_empty_data_structured_event( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: # Testing if cloudevent breaks when no structured data field present attributes = { "specversion": specversion, @@ -352,7 +405,9 @@ def test_empty_data_structured_event(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_empty_data_binary_event(specversion, cloudevents_implementation): +def test_empty_data_binary_event( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: # Testing if cloudevent breaks when no structured data field present headers = { "Content-Type": "application/octet-stream", @@ -372,12 +427,14 @@ def test_empty_data_binary_event(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_valid_structured_events(specversion, cloudevents_implementation): +def test_valid_structured_events( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: # Test creating multiple cloud events - events_queue = [] + events_queue: list[AnyPydanticCloudEvent] = [] num_cloudevents = 30 for i in range(num_cloudevents): - event = { + raw_event = { "id": f"id{i}", "source": f"source{i}.com.test", "type": "cloudevent.test.type", @@ -387,11 +444,12 @@ def test_valid_structured_events(specversion, cloudevents_implementation): events_queue.append( cloudevents_implementation["from_http"]( {"content-type": "application/cloudevents+json"}, - json.dumps(event), + json.dumps(raw_event), ) ) for i, event in enumerate(events_queue): + assert isinstance(event.data, dict) assert event["id"] == f"id{i}" assert event["source"] == f"source{i}.com.test" assert event["specversion"] == specversion @@ -399,7 +457,9 @@ def test_valid_structured_events(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_structured_no_content_type(specversion, cloudevents_implementation): +def test_structured_no_content_type( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: # Test creating multiple cloud events data = { "id": "id", @@ -410,6 +470,7 @@ def test_structured_no_content_type(specversion, cloudevents_implementation): } event = cloudevents_implementation["from_http"]({}, json.dumps(data)) + assert isinstance(event.data, dict) assert event["id"] == "id" assert event["source"] == "source.com.test" assert event["specversion"] == specversion @@ -437,7 +498,9 @@ def test_is_binary(): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_cloudevent_repr(specversion, cloudevents_implementation): +def test_cloudevent_repr( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: headers = { "Content-Type": "application/octet-stream", "ce-specversion": specversion, @@ -454,7 +517,9 @@ def test_cloudevent_repr(specversion, cloudevents_implementation): @pytest.mark.parametrize("specversion", ["1.0", "0.3"]) -def test_none_data_cloudevent(specversion, cloudevents_implementation): +def test_none_data_cloudevent( + specversion: str, cloudevents_implementation: PydanticImplementation +) -> None: event = cloudevents_implementation["event"]( { "source": "", @@ -466,7 +531,7 @@ def test_none_data_cloudevent(specversion, cloudevents_implementation): to_structured(event) -def test_wrong_specversion(cloudevents_implementation): +def test_wrong_specversion(cloudevents_implementation: PydanticImplementation) -> None: headers = {"Content-Type": "application/cloudevents+json"} data = json.dumps( { @@ -481,15 +546,19 @@ def test_wrong_specversion(cloudevents_implementation): assert "Found invalid specversion 0.2" in str(e.value) -def test_invalid_data_format_structured_from_http(cloudevents_implementation): +def test_invalid_data_format_structured_from_http( + cloudevents_implementation: PydanticImplementation, +) -> None: headers = {"Content-Type": "application/cloudevents+json"} data = 20 with pytest.raises(cloud_exceptions.InvalidStructuredJSON) as e: - cloudevents_implementation["from_http"](headers, data) + cloudevents_implementation["from_http"](headers, data) # type: ignore[type-var] # intentionally wrong type # noqa: E501 assert "Expected json of type (str, bytes, bytearray)" in str(e.value) -def test_wrong_specversion_to_request(cloudevents_implementation): +def test_wrong_specversion_to_request( + cloudevents_implementation: PydanticImplementation, +) -> None: event = cloudevents_implementation["event"]({"source": "s", "type": "t"}, None) with pytest.raises(cloud_exceptions.InvalidRequiredFields) as e: event["specversion"] = "0.2" @@ -513,7 +582,9 @@ def test_is_structured(): assert not is_structured(headers) -def test_empty_json_structured(cloudevents_implementation): +def test_empty_json_structured( + cloudevents_implementation: PydanticImplementation, +) -> None: headers = {"Content-Type": "application/cloudevents+json"} data = "" with pytest.raises(cloud_exceptions.MissingRequiredFields) as e: @@ -521,7 +592,9 @@ def test_empty_json_structured(cloudevents_implementation): assert "Failed to read specversion from both headers and data" in str(e.value) -def test_uppercase_headers_with_none_data_binary(cloudevents_implementation): +def test_uppercase_headers_with_none_data_binary( + cloudevents_implementation: PydanticImplementation, +) -> None: headers = { "Ce-Id": "my-id", "Ce-Source": "", @@ -538,7 +611,7 @@ def test_uppercase_headers_with_none_data_binary(cloudevents_implementation): assert new_data is None -def test_generic_exception(cloudevents_implementation): +def test_generic_exception(cloudevents_implementation: PydanticImplementation) -> None: headers = {"Content-Type": "application/cloudevents+json"} data = json.dumps( { @@ -554,7 +627,7 @@ def test_generic_exception(cloudevents_implementation): e.errisinstance(cloud_exceptions.MissingRequiredFields) with pytest.raises(cloud_exceptions.GenericException) as e: - cloudevents_implementation["from_http"]({}, 123) + cloudevents_implementation["from_http"]({}, 123) # type: ignore[type-var] # intentionally wrong type # noqa: E501 e.errisinstance(cloud_exceptions.InvalidStructuredJSON) with pytest.raises(cloud_exceptions.GenericException) as e: @@ -569,7 +642,9 @@ def test_generic_exception(cloudevents_implementation): e.errisinstance(cloud_exceptions.DataMarshallerError) -def test_non_dict_data_no_headers_bug(cloudevents_implementation): +def test_non_dict_data_no_headers_bug( + cloudevents_implementation: PydanticImplementation, +) -> None: # Test for issue #116 headers = {"Content-Type": "application/cloudevents+json"} data = "123" diff --git a/mypy.ini b/mypy.ini index 39426375..d8fb9cc0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] plugins = pydantic.mypy -python_version = 3.7 +python_version = 3.8 pretty = True show_error_context = True diff --git a/requirements/dev.txt b/requirements/dev.txt index 63872949..fa910283 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -5,3 +5,4 @@ pep8-naming flake8-print tox pre-commit +mypy diff --git a/requirements/mypy.txt b/requirements/mypy.txt new file mode 100644 index 00000000..2f2229cf --- /dev/null +++ b/requirements/mypy.txt @@ -0,0 +1,5 @@ +mypy +# mypy has the pydantic plugin enabled +pydantic>=2.0.0,<3.0 +types-requests +deprecation>=2.0,<3.0 diff --git a/samples/http-image-cloudevents/client.py b/samples/http-image-cloudevents/client.py index 021c1f56..ee003942 100644 --- a/samples/http-image-cloudevents/client.py +++ b/samples/http-image-cloudevents/client.py @@ -25,7 +25,7 @@ image_bytes = resp.content -def send_binary_cloud_event(url: str): +def send_binary_cloud_event(url: str) -> None: # Create cloudevent attributes = { "type": "com.example.string", @@ -42,7 +42,7 @@ def send_binary_cloud_event(url: str): print(f"Sent {event['id']} of type {event['type']}") -def send_structured_cloud_event(url: str): +def send_structured_cloud_event(url: str) -> None: # Create cloudevent attributes = { "type": "com.example.base64", diff --git a/setup.py b/setup.py index 95ccf97c..f4249978 100644 --- a/setup.py +++ b/setup.py @@ -65,11 +65,11 @@ def get_version(rel_path): "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Typing :: Typed", ], keywords="CloudEvents Eventing Serverless", diff --git a/tox.ini b/tox.ini index a5cbdfa7..d5f1d984 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{37,38,39,310,311},lint +envlist = py{39,310,311,312,313},lint,mypy,mypy-samples-{image,json} skipsdist = True [testenv] @@ -12,7 +12,7 @@ setenv = commands = pytest {env:PYTESTARGS} {posargs} [testenv:reformat] -basepython = python3.11 +basepython = python3.12 deps = black isort @@ -21,7 +21,7 @@ commands = isort cloudevents samples [testenv:lint] -basepython = python3.11 +basepython = python3.12 deps = black isort @@ -30,3 +30,21 @@ commands = black --check . isort -c cloudevents samples flake8 cloudevents samples --ignore W503,E731 --extend-ignore E203 --max-line-length 88 + +[testenv:mypy] +basepython = python3.12 +deps = + -r{toxinidir}/requirements/mypy.txt + # mypy needs test dependencies to check test modules + -r{toxinidir}/requirements/test.txt +commands = mypy cloudevents + +[testenv:mypy-samples-{image,json}] +basepython = python3.12 +setenv = + mypy-samples-image: SAMPLE_DIR={toxinidir}/samples/http-image-cloudevents + mypy-samples-json: SAMPLE_DIR={toxinidir}/samples/http-json-cloudevents +deps = + -r{toxinidir}/requirements/mypy.txt + -r{env:SAMPLE_DIR}/requirements.txt +commands = mypy {env:SAMPLE_DIR}