From eedc61e9b0f922cd76c9be78a049f908e8e621be Mon Sep 17 00:00:00 2001 From: Yurii Serhiichuk Date: Sun, 26 May 2024 21:49:35 +0300 Subject: [PATCH 1/9] Update CI and tooling (#236) * Update pre-commit hooks Signed-off-by: Yurii Serhiichuk * Add Python 3.12 Signed-off-by: Yurii Serhiichuk * Drop python 3.7 and add 3.12 to TOX Signed-off-by: Yurii Serhiichuk * Migrate to latest action versions. Drop v3.7 from CI and add 3.12 Signed-off-by: Yurii Serhiichuk * Migrate to Python 3.8 Signed-off-by: Yurii Serhiichuk * Fix changelog message. Signed-off-by: Yurii Serhiichuk --------- Signed-off-by: Yurii Serhiichuk --- .github/workflows/main.yml | 10 +++++----- .github/workflows/pypi-release.yml | 9 +++++---- .pre-commit-config.yaml | 6 +++--- CHANGELOG.md | 4 ++++ mypy.ini | 2 +- setup.py | 1 - tox.ini | 2 +- 7 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f1a6ae47..107bf9e7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,9 +7,9 @@ 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' cache: 'pip' @@ -22,13 +22,13 @@ jobs: test: strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python: ['3.8', '3.9', '3.10', '3.11'] 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..4cb248bc 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -12,15 +12,16 @@ jobs: name: Build source distribution runs-on: ubuntu-22.04 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 @@ -30,7 +31,7 @@ jobs: 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 @@ -40,7 +41,7 @@ jobs: 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 15ab6545..cc893e5e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,17 +6,17 @@ repos: - id: end-of-file-fixer - id: check-toml - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort args: [ "--profile", "black", "--filter-files" ] - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 24.4.2 hooks: - id: black language_version: python3.11 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 + rev: v1.10.0 hooks: - id: mypy files: ^(cloudevents/) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fcb0e5..66dc58d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Dropped Python3.7 from CI while its EOL. + ## [1.10.1] ### Fixed 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/setup.py b/setup.py index 95ccf97c..a4e4befc 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,6 @@ 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", diff --git a/tox.ini b/tox.ini index a5cbdfa7..0436a1be 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{37,38,39,310,311},lint +envlist = py{38,39,310,311,312},lint skipsdist = True [testenv] From 11520e35e134b9aa749859981d100e27fe6a0e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20D=2E=20Batista?= Date: Sun, 26 May 2024 15:51:36 -0300 Subject: [PATCH 2/9] Pydantic v2 (#235) * Fixes examples when using Pydantic V2 Signed-off-by: Fabio Batista * When type checking, uses the latest (V2) version of Pydantic Signed-off-by: Fabio Batista --------- Signed-off-by: Fabio Batista Co-authored-by: Yurii Serhiichuk --- .pre-commit-config.yaml | 2 +- cloudevents/pydantic/__init__.py | 33 ++++++++++++++++++++------------ cloudevents/pydantic/v2/event.py | 18 ++++++++--------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc893e5e..75ad2ef1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,4 +24,4 @@ repos: types: [ python ] args: [ ] additional_dependencies: - - "pydantic" + - "pydantic~=2.7" 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/v2/event.py b/cloudevents/pydantic/v2/event.py index 4ae8bb5c..643794c1 100644 --- a/cloudevents/pydantic/v2/event.py +++ b/cloudevents/pydantic/v2/event.py @@ -51,53 +51,53 @@ def create( 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, ) From 16441d79f433f98403e327e4015378be25f3b457 Mon Sep 17 00:00:00 2001 From: Vivian <118199397+vivjd@users.noreply.github.com> Date: Sun, 26 May 2024 11:56:16 -0700 Subject: [PATCH 3/9] Modified content-type to abide by attribute naming conventions for cloudevents (#232) * fix: changed content-type to a valid attribute Signed-off-by: vivjd * fix: changed headers back to content-type Signed-off-by: Vivian <118199397+vivjd@users.noreply.github.com> Signed-off-by: vivjd * modified kafka test cases to match datacontenttype Signed-off-by: vivjd * fix: updated kafka/conversion.py and test cases to check for valid attributes Signed-off-by: vivjd --------- Signed-off-by: vivjd Signed-off-by: Vivian <118199397+vivjd@users.noreply.github.com> Co-authored-by: Yurii Serhiichuk --- cloudevents/kafka/conversion.py | 17 ++++++++++------- cloudevents/tests/test_kafka_conversions.py | 14 +++++++------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/cloudevents/kafka/conversion.py b/cloudevents/kafka/conversion.py index 832594d1..97c355f2 100644 --- a/cloudevents/kafka/conversion.py +++ b/cloudevents/kafka/conversion.py @@ -87,10 +87,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") @@ -126,7 +126,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() @@ -189,8 +189,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) @@ -255,7 +255,10 @@ 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() if event_type: result = event_type.create(attributes, data) else: diff --git a/cloudevents/tests/test_kafka_conversions.py b/cloudevents/tests/test_kafka_conversions.py index 696e75cb..5580773a 100644 --- a/cloudevents/tests/test_kafka_conversions.py +++ b/cloudevents/tests/test_kafka_conversions.py @@ -59,7 +59,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 +123,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 +163,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 +205,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 +328,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 +474,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 +487,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() ) From c6c7e8c2f92d673042cd4690c539d68f27c50623 Mon Sep 17 00:00:00 2001 From: Yurii Serhiichuk Date: Thu, 20 Jun 2024 08:31:13 +0200 Subject: [PATCH 4/9] Release/v1.11.0 (#237) * Add missing changelog items Signed-off-by: Yurii Serhiichuk * Bump version Signed-off-by: Yurii Serhiichuk --------- Signed-off-by: Yurii Serhiichuk --- CHANGELOG.md | 12 +++++++++++- cloudevents/__init__.py | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66dc58d5..e2825976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. +- Dropped Python3.7 from CI while its EOL. ([#236]) ## [1.10.1] @@ -194,6 +200,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 @@ -277,3 +284,6 @@ 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 diff --git a/cloudevents/__init__.py b/cloudevents/__init__.py index c6e11514..1f52fdbb 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.11.0" From efca352e21b5cdd61b0f7afbaf191859f399194b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20H=C3=B6sler?= Date: Wed, 30 Oct 2024 10:41:03 +0100 Subject: [PATCH 5/9] fix kafka unmarshaller args typing and defaults (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix kafka unmarshaller args typing and defaults Signed-off-by: Christoph Hösler * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: Christoph Hösler Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- cloudevents/kafka/conversion.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cloudevents/kafka/conversion.py b/cloudevents/kafka/conversion.py index 97c355f2..bfddca61 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): @@ -109,7 +114,7 @@ def to_binary( def from_binary( message: KafkaMessage, event_type: typing.Optional[typing.Type[AnyCloudEvent]] = None, - data_unmarshaller: typing.Optional[types.MarshallerType] = None, + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> AnyCloudEvent: """ Returns a CloudEvent from a KafkaMessage in binary format. @@ -208,7 +213,7 @@ def to_structured( def from_structured( message: KafkaMessage, event_type: typing.Optional[typing.Type[AnyCloudEvent]] = None, - data_unmarshaller: typing.Optional[types.MarshallerType] = None, + data_unmarshaller: typing.Optional[types.UnmarshallerType] = None, envelope_unmarshaller: typing.Optional[types.UnmarshallerType] = None, ) -> AnyCloudEvent: """ @@ -222,7 +227,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) From 96cfaa6529dfbd7a179d6433cae950c890de54a6 Mon Sep 17 00:00:00 2001 From: Yurii Serhiichuk Date: Wed, 30 Oct 2024 11:54:36 +0200 Subject: [PATCH 6/9] chore: release 1.11.1 (#241) Signed-off-by: Yurii Serhiichuk --- CHANGELOG.md | 6 ++++++ cloudevents/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2825976..458a1dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.11.1] + +### Fixed +- Kafka `conversion` marshaller and unmarshaller typings ([#240]) + ## [1.11.0] ### Fixed @@ -287,3 +292,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#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 diff --git a/cloudevents/__init__.py b/cloudevents/__init__.py index 1f52fdbb..d332910d 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.11.0" +__version__ = "1.11.1" From c5645d8fcf03432639727b9a7f6508c3059a1673 Mon Sep 17 00:00:00 2001 From: Yurii Serhiichuk Date: Sat, 9 Nov 2024 20:27:52 +0200 Subject: [PATCH 7/9] chpre: disable attestations while we're not using trusted publishing (#243) Signed-off-by: Yurii Serhiichuk --- .github/workflows/pypi-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 4cb248bc..2b1dbf0c 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -52,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 From 37ae369ced2d1820e01e885d1998df3f9609c61c Mon Sep 17 00:00:00 2001 From: Hal Blackburn Date: Fri, 23 May 2025 20:26:18 +0100 Subject: [PATCH 8/9] Improve public API type annotations & fix unit test type errors (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: improve typing of functions returning AnyCloudEvent kafka.conversion.from_binary() and from_structured() return AnyCloudEvent type var according to their event_type argument, but when event_type is None, type checkers cannot infer the return type. We now use an overload to declare that the return type is http.CloudEvent when event_type is None. Previously users had to explicitly annotate this type when calling without event_type. This happens quite a lot in this repo's test_kafka_conversions.py — this fixes quite a few type errors like: > error: Need type annotation for "result" [var-annotated] Signed-off-by: Hal Blackburn * chore: type v1.Event chainable Set*() methods The v1.Event self-returning Set*() methods like SetData() were returning BaseEvent, which doesn't declare the same Set* methods. As a result, chaining more than one Set* method would make the return type unknown. This was causing type errors in test_event_pipeline.py. The Set*() methods now return the Self type. Signed-off-by: Hal Blackburn * chore: fix type errors in tests mypy was failing with lots of type errors in test modules. I've not annotated all fixtures, mostly fixed existing type errors. Signed-off-by: Hal Blackburn * chore: allow non-dict headers types in from_http() from_http() conversion function was requiring its headers argument to be a typing.Dict, which makes it incompatible with headers types of http libraries, which support features like multiple values per key. typing.Mapping and even _typeshed.SupportsItems do not cover these types. For example, samples/http-image-cloudevents/image_sample_server.py was failing to type check where it calls `from_http(request.headers, ...)`. To support these kind of headers types in from_http(), we now define our own SupportsDuplicateItems protocol, which is broader than _typeshed.SupportsItems. I've only applied this to from_http(), as typing.Mapping is OK for most other methods that accept dict-like objects, and using this more lenient interface everywhere would impose restrictions on our implementation, even though it might be more flexible for users. Signed-off-by: Hal Blackburn * build: run mypy via tox Tox now runs mypy on cloudevents itself, and the samples. Signed-off-by: Hal Blackburn * build(ci): run mypy in CI alongside linting Signed-off-by: Hal Blackburn * chore: fix minor mypy type complaint in samples Signed-off-by: Hal Blackburn * feat: use Mapping, not Dict for input arguments Mapping imposes less restrictions on callers, because it's read-only and allows non-dict types to be passed without copying them as dict(), or passing dict-like values and ignoring the resulting type error. Signed-off-by: Hal Blackburn * chore: fix tests on py3.8 Tests were failing because the sanic dependency dropped support for py3.8 in its current release. sanic is now pinned to the last compatible version for py3.8 only. Signed-off-by: Hal Blackburn * feat: support new model_validate_json() kwargs Pydantic added by_alias and by_name keyword arguments to BaseModel.model_validate_json in 2.11.1: https://github.com/pydantic/pydantic/commit/acb0f10fda1c78441e052c57b4288bc91431f852 This caused mypy to report that that the Pydantic v2 CloudEvent did not override model_validate_json() correctly. Our override now accepts these newly-added arguments. They have no effect, as the implementation does not use Pydantic to validate the JSON, but we also don't use field aliases, so the only effect they could have in the superclass would be to raise an error if they're both False. Signed-off-by: Hal Blackburn * chore: accept Mapping as well as SupportsDuplicateItems Although our types.SupportsDuplicateItems type is wider than Dict and Mapping, it's not a familar type to users, so explicitly accepting Mapping in the from_http() functions should make it more clear to users that a dict-like object is required for the headers argument. Signed-off-by: Hal Blackburn * chore: constrain deps to maintain py 3.8 support Python 3.8 is unsupported and dependencies (such as pydantic) are now shipping releases that fail to type check with mypy running in 3.8 compatibility mode. We run mypy in py 3.8 compatibility mode, so the mypy tox environments must only use deps that support 3.8. And unit tests run by py 3.8 must only use deps that support 3.8. To constrain the deps for 3.8 support, we use two constraint files, one for general environments that only constrains the dependencies that python 3.8 interpreters use, and another for mypy that constraints the dependencies that all interpreters use. Signed-off-by: Hal Blackburn --------- Signed-off-by: Hal Blackburn --- .github/workflows/main.yml | 2 +- cloudevents/abstract/event.py | 2 +- cloudevents/conversion.py | 6 +- cloudevents/http/conversion.py | 6 +- cloudevents/http/event.py | 6 +- cloudevents/kafka/conversion.py | 48 +++++- cloudevents/pydantic/v1/conversion.py | 6 +- cloudevents/pydantic/v1/event.py | 6 +- cloudevents/pydantic/v2/conversion.py | 6 +- cloudevents/pydantic/v2/event.py | 8 +- cloudevents/sdk/event/v1.py | 23 +-- cloudevents/sdk/types.py | 16 ++ cloudevents/tests/test_converters.py | 6 +- .../test_event_from_request_converter.py | 2 +- cloudevents/tests/test_event_pipeline.py | 2 +- cloudevents/tests/test_http_events.py | 13 +- cloudevents/tests/test_kafka_conversions.py | 7 +- cloudevents/tests/test_marshaller.py | 4 +- cloudevents/tests/test_pydantic_events.py | 151 +++++++++++++----- requirements/constraints.txt | 10 ++ requirements/dev.txt | 1 + requirements/mypy-constraints.txt | 9 ++ requirements/mypy.txt | 5 + samples/http-image-cloudevents/client.py | 4 +- tox.ini | 24 ++- 25 files changed, 288 insertions(+), 85 deletions(-) create mode 100644 requirements/constraints.txt create mode 100644 requirements/mypy-constraints.txt create mode 100644 requirements/mypy.txt diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 107bf9e7..b5eb4b8f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: - 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: 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 bfddca61..bdf2acab 100644 --- a/cloudevents/kafka/conversion.py +++ b/cloudevents/kafka/conversion.py @@ -111,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, + 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. @@ -144,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 @@ -210,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, + 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. @@ -264,8 +303,9 @@ def from_structured( 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/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 643794c1..34a9b659 100644 --- a/cloudevents/pydantic/v2/event.py +++ b/cloudevents/pydantic/v2/event.py @@ -44,7 +44,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) @@ -103,7 +105,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, ): @@ -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 5580773a..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 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/requirements/constraints.txt b/requirements/constraints.txt new file mode 100644 index 00000000..648d97e5 --- /dev/null +++ b/requirements/constraints.txt @@ -0,0 +1,10 @@ +# This is a requirements constraint file, see: +# https://pip.pypa.io/en/stable/user_guide/#constraints-files + +# sanic stopped supporting 3.8 in 24.12: +# https://sanic.dev/en/release-notes/changelog.html#version-24120- +sanic<24.12.0 ; python_version == '3.8' + +# pydantic stopped supporting 3.8 in 2.11.0: +# https://github.com/pydantic/pydantic/releases/tag/v2.11.0 +pydantic<2.11.0 ; python_version == '3.8' 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-constraints.txt b/requirements/mypy-constraints.txt new file mode 100644 index 00000000..82c5f95e --- /dev/null +++ b/requirements/mypy-constraints.txt @@ -0,0 +1,9 @@ +# This is a requirements constraint file, see: +# https://pip.pypa.io/en/stable/user_guide/#constraints-files + +# Because we run mypy in python 3.8 compatibility mode, dependencies must be +# versions that support 3.8. + +# pydantic stopped supporting 3.8 in 2.11.0: +# https://github.com/pydantic/pydantic/releases/tag/v2.11.0 +pydantic<2.11.0 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/tox.ini b/tox.ini index 0436a1be..88f0bd1f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,39,310,311,312},lint +envlist = py{38,39,310,311,312},lint,mypy,mypy-samples-{image,json} skipsdist = True [testenv] @@ -8,6 +8,7 @@ deps = -r{toxinidir}/requirements/test.txt -r{toxinidir}/requirements/publish.txt setenv = + PIP_CONSTRAINT={toxinidir}/requirements/constraints.txt PYTESTARGS = -v -s --tb=long --cov=cloudevents --cov-report term-missing --cov-fail-under=95 commands = pytest {env:PYTESTARGS} {posargs} @@ -30,3 +31,24 @@ commands = black --check . isort -c cloudevents samples flake8 cloudevents samples --ignore W503,E731 --extend-ignore E203 --max-line-length 88 + +[testenv:mypy] +basepython = python3.11 +setenv = + PIP_CONSTRAINT={toxinidir}/requirements/mypy-constraints.txt +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.11 +setenv = + PIP_CONSTRAINT={toxinidir}/requirements/mypy-constraints.txt + 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} From a38933d7ab0fa9827cd23be9b8e7c118436457fe Mon Sep 17 00:00:00 2001 From: Yurii Serhiichuk Date: Mon, 2 Jun 2025 20:58:00 +0200 Subject: [PATCH 9/9] Drop EOL Python 3.8 support (#249) * chore: add missing changelog items Signed-off-by: Yurii Serhiichuk * chore: drop Python 3.8 support Signed-off-by: Yurii Serhiichuk * chore: add a changelog item on Python 3.8 removal Signed-off-by: Yurii Serhiichuk * chore: remove mypy-constrains reference as we don't need it anymore Signed-off-by: Yurii Serhiichuk * chore: Update pre-commit check versions. Signed-off-by: Yurii Serhiichuk * chore: fix isort pre-commit Signed-off-by: Yurii Serhiichuk * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore: Use Python 3.12 as base version Signed-off-by: Yurii Serhiichuk --------- Signed-off-by: Yurii Serhiichuk Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/main.yml | 4 ++-- .github/workflows/pypi-release.yml | 8 ++++---- .pre-commit-config.yaml | 8 ++++---- CHANGELOG.md | 8 ++++++++ cloudevents/__init__.py | 2 +- requirements/constraints.txt | 10 ---------- requirements/mypy-constraints.txt | 9 --------- setup.py | 3 ++- tox.ini | 14 +++++--------- 9 files changed, 26 insertions(+), 40 deletions(-) delete mode 100644 requirements/constraints.txt delete mode 100644 requirements/mypy-constraints.txt diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b5eb4b8f..52e7c9a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ jobs: - name: Setup Python 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 @@ -22,7 +22,7 @@ jobs: test: strategy: matrix: - python: ['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: diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 2b1dbf0c..eeebb883 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -10,7 +10,7 @@ on: jobs: build_dist: name: Build source distribution - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: @@ -27,7 +27,7 @@ jobs: - 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: @@ -35,9 +35,9 @@ jobs: 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75ad2ef1..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.13.2 + rev: 6.0.1 hooks: - id: isort args: [ "--profile", "black", "--filter-files" ] - repo: https://github.com/psf/black - rev: 24.4.2 + rev: 25.1.0 hooks: - id: black language_version: python3.11 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.16.0 hooks: - id: mypy files: ^(cloudevents/) diff --git a/CHANGELOG.md b/CHANGELOG.md index 458a1dd7..47023884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,17 @@ 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] @@ -293,3 +300,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#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 d332910d..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.11.1" +__version__ = "1.12.0" diff --git a/requirements/constraints.txt b/requirements/constraints.txt deleted file mode 100644 index 648d97e5..00000000 --- a/requirements/constraints.txt +++ /dev/null @@ -1,10 +0,0 @@ -# This is a requirements constraint file, see: -# https://pip.pypa.io/en/stable/user_guide/#constraints-files - -# sanic stopped supporting 3.8 in 24.12: -# https://sanic.dev/en/release-notes/changelog.html#version-24120- -sanic<24.12.0 ; python_version == '3.8' - -# pydantic stopped supporting 3.8 in 2.11.0: -# https://github.com/pydantic/pydantic/releases/tag/v2.11.0 -pydantic<2.11.0 ; python_version == '3.8' diff --git a/requirements/mypy-constraints.txt b/requirements/mypy-constraints.txt deleted file mode 100644 index 82c5f95e..00000000 --- a/requirements/mypy-constraints.txt +++ /dev/null @@ -1,9 +0,0 @@ -# This is a requirements constraint file, see: -# https://pip.pypa.io/en/stable/user_guide/#constraints-files - -# Because we run mypy in python 3.8 compatibility mode, dependencies must be -# versions that support 3.8. - -# pydantic stopped supporting 3.8 in 2.11.0: -# https://github.com/pydantic/pydantic/releases/tag/v2.11.0 -pydantic<2.11.0 diff --git a/setup.py b/setup.py index a4e4befc..f4249978 100644 --- a/setup.py +++ b/setup.py @@ -65,10 +65,11 @@ def get_version(rel_path): "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "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 88f0bd1f..d5f1d984 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,39,310,311,312},lint,mypy,mypy-samples-{image,json} +envlist = py{39,310,311,312,313},lint,mypy,mypy-samples-{image,json} skipsdist = True [testenv] @@ -8,12 +8,11 @@ deps = -r{toxinidir}/requirements/test.txt -r{toxinidir}/requirements/publish.txt setenv = - PIP_CONSTRAINT={toxinidir}/requirements/constraints.txt PYTESTARGS = -v -s --tb=long --cov=cloudevents --cov-report term-missing --cov-fail-under=95 commands = pytest {env:PYTESTARGS} {posargs} [testenv:reformat] -basepython = python3.11 +basepython = python3.12 deps = black isort @@ -22,7 +21,7 @@ commands = isort cloudevents samples [testenv:lint] -basepython = python3.11 +basepython = python3.12 deps = black isort @@ -33,9 +32,7 @@ commands = flake8 cloudevents samples --ignore W503,E731 --extend-ignore E203 --max-line-length 88 [testenv:mypy] -basepython = python3.11 -setenv = - PIP_CONSTRAINT={toxinidir}/requirements/mypy-constraints.txt +basepython = python3.12 deps = -r{toxinidir}/requirements/mypy.txt # mypy needs test dependencies to check test modules @@ -43,9 +40,8 @@ deps = commands = mypy cloudevents [testenv:mypy-samples-{image,json}] -basepython = python3.11 +basepython = python3.12 setenv = - PIP_CONSTRAINT={toxinidir}/requirements/mypy-constraints.txt mypy-samples-image: SAMPLE_DIR={toxinidir}/samples/http-image-cloudevents mypy-samples-json: SAMPLE_DIR={toxinidir}/samples/http-json-cloudevents deps =