From 72ced435ab60929ad2ce764cb559ccadb460a5d5 Mon Sep 17 00:00:00 2001 From: David Fridrich Date: Mon, 13 Apr 2026 13:35:33 +0200 Subject: [PATCH] bump cloudevents to 2.* --- CHANGELOG.md | 3 ++ cmd/fcloudevent/main.py | 48 ++++++++++++++-------------- poetry.lock | 59 +++++++++++++++++++++++++---------- pyproject.toml | 4 +-- src/func_python/cloudevent.py | 28 +++++++++-------- tests/test_cloudevent.py | 28 ++++++++--------- 6 files changed, 100 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e71626..bcb34ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added ### Changed + +- Bumping cloudevents dep to 2.0 + ### Deprecated ### Removed ### Fixed diff --git a/cmd/fcloudevent/main.py b/cmd/fcloudevent/main.py index 2a81bff6..00c91ba4 100644 --- a/cmd/fcloudevent/main.py +++ b/cmd/fcloudevent/main.py @@ -1,7 +1,7 @@ import argparse import logging from func_python.cloudevent import serve -from cloudevents.http import CloudEvent +from cloudevents.core.v1.event import CloudEvent # Set the default logging level to INFO logging.basicConfig(level=logging.INFO) @@ -30,30 +30,30 @@ async def handle(scope, receive, send): event = scope["event"] if not event: error_event = CloudEvent( - {"type": "dev.functions.error", "source": "/fcloudevent/error"}, - {"message": "No CloudEvent found in scope"} + attributes={"type": "dev.functions.error", "source": "/fcloudevent/error"}, + data={"message": "No CloudEvent found in scope"} ) await send(error_event, 500) return - - logging.info(f"Received CloudEvent: type={event['type']}, source={event['source']}") - + + logging.info(f"Received CloudEvent: type={event.get_type()}, source={event.get_source()}") + # Handle event data - it might be bytes or dict - event_data = event.data + event_data = event.get_data() if isinstance(event_data, bytes): import json event_data = json.loads(event_data) logging.info(f"CloudEvent data: {event_data}") - + response_event = CloudEvent( - { + attributes={ "type": "com.example.response.static", "source": "/fcloudevent/static", }, - { + data={ "message": "OK: static CloudEvent handler", - "received_event_type": event['type'], - "received_event_source": event['source'], + "received_event_type": event.get_type(), + "received_event_source": event.get_source(), "received_data": event_data } ) @@ -79,34 +79,34 @@ async def handle(self, scope, receive, send): if not event: # This shouldn't happen with our fix, but let's handle it gracefully error_event = CloudEvent( - {"type": "dev.functions.error", "source": "/fcloudevent/error"}, - {"message": "No CloudEvent found in scope"} + attributes={"type": "dev.functions.error", "source": "/fcloudevent/error"}, + data={"message": "No CloudEvent found in scope"} ) await send(error_event, 500) return - + self.event_count += 1 - - logging.info(f"Received CloudEvent #{self.event_count}: type={event['type']}, source={event['source']}") - + + logging.info(f"Received CloudEvent #{self.event_count}: type={event.get_type()}, source={event.get_source()}") + # Handle event data - it might be bytes or dict - event_data = event.data + event_data = event.get_data() if isinstance(event_data, bytes): import json event_data = json.loads(event_data) logging.info(f"CloudEvent data: {event_data}") logging.info(f"Total events processed: {self.event_count}") - + response_event = CloudEvent( - { + attributes={ "type": "com.example.response.instanced", "source": "/fcloudevent/instanced", }, - { + data={ "message": "OK: instanced CloudEvent handler", "event_count": self.event_count, - "received_event_type": event['type'], - "received_event_source": event['source'], + "received_event_type": event.get_type(), + "received_event_source": event.get_source(), "received_data": event_data } ) diff --git a/poetry.lock b/poetry.lock index eb01181c..58a6d06d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. [[package]] name = "anyio" @@ -20,7 +20,7 @@ typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -37,21 +37,19 @@ files = [ [[package]] name = "cloudevents" -version = "1.11.0" +version = "2.0.0" description = "CloudEvents Python SDK" optional = false -python-versions = "*" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "cloudevents-1.11.0-py3-none-any.whl", hash = "sha256:77edb4f2b01f405c44ea77120c3213418dbc63d8859f98e9e85de875502b8a76"}, - {file = "cloudevents-1.11.0.tar.gz", hash = "sha256:5be990583e99f3b08af5a709460e20b25cb169270227957a20b47a6ec8635e66"}, + {file = "cloudevents-2.0.0-py3-none-any.whl", hash = "sha256:babb257989a933b18312897c3bf3a7cc65e270831227822f2db10b50d1278546"}, + {file = "cloudevents-2.0.0.tar.gz", hash = "sha256:3224851b2ac902b868ed4bc1aa10772ada8cdf85f180322b12968d90a1dceef2"}, ] [package.dependencies] deprecation = ">=2.0,<3.0" - -[package.extras] -pydantic = ["pydantic (>=1.0.0,<3.0)"] +python-dateutil = ">=2.8.2" [[package]] name = "colorama" @@ -88,7 +86,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -178,7 +176,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -210,7 +208,7 @@ wsproto = ">=0.14.0" docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"] h3 = ["aioquic (>=0.9.0,<1.0)"] trio = ["trio (>=0.22.0)"] -uvloop = ["uvloop (>=0.18)"] +uvloop = ["uvloop (>=0.18) ; platform_system != \"Windows\""] [[package]] name = "hyperframe" @@ -314,6 +312,33 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -333,7 +358,7 @@ description = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout optional = false python-versions = "*" groups = ["main"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"}, {file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"}, @@ -350,7 +375,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -393,7 +418,7 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -416,5 +441,5 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.1" -python-versions = "^3.8" -content-hash = "1f5ac8df08ae05d671865b167125f04f831e1663adb9fc2f88134e5681f9d305" +python-versions = "^3.10" +content-hash = "9efded3d86ab82b9c523f626f6d2cedeeab1fc5e3af7a8c3d06bd53faca5eab2" diff --git a/pyproject.toml b/pyproject.toml index f5ae26be..5b72bf42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,9 @@ license = "Apache-2.0" repository = "https://github.com/knative-extensions/func-python" [tool.poetry.dependencies] -python = "^3.8" +python = "^3.10" hypercorn = "^0.17.3" -cloudevents = "^1.11.0" +cloudevents = "^2.0.0" [tool.pytest.ini_options] pythonpath = ["src"] diff --git a/src/func_python/cloudevent.py b/src/func_python/cloudevent.py index 26a4cc4e..8465803a 100644 --- a/src/func_python/cloudevent.py +++ b/src/func_python/cloudevent.py @@ -6,9 +6,11 @@ import hypercorn.config import hypercorn.asyncio -from cloudevents.http import from_http, CloudEvent -from cloudevents.conversion import to_structured, to_binary -from cloudevents.exceptions import MissingRequiredFields, InvalidRequiredFields +from cloudevents.core.v1.event import CloudEvent +from cloudevents.core.bindings.http import ( + from_http_event, to_structured_event, to_binary_event, HTTPMessage, +) +from cloudevents.core.exceptions import CloudEventValidationError import func_python.sock @@ -50,8 +52,8 @@ async def handle(self, scope, receive, send): class ASGIApplication(): - """ ASGIApplication is a wrapper around a Function instance which - exposes it as an ASGI Application. + """ ASGIApplication is a wrapper around a Function instance which + exposes it as an ASGI Application. """ def __init__(self, f): self.f = f @@ -153,12 +155,12 @@ async def __call__(self, scope, receive, send): send = CloudEventSender(send) # Delegate processing to user's Function await self.f.handle(scope, receive, send) - except (MissingRequiredFields, InvalidRequiredFields) as e: + except (CloudEventValidationError, ValueError) as e: # Log the non-CloudEvent request for debugging logging.warning(f"Received non-CloudEvent request: {scope['method']} {scope['path']}") headers_dict = {k.decode('utf-8'): v.decode('utf-8') for k, v in scope.get('headers', [])} logging.debug(f"Request headers: {headers_dict}") - + # Return 400 Bad Request for non-CloudEvent requests await send({ 'type': 'http.response.start', @@ -231,7 +233,7 @@ async def decode_event(scope, receive): k.decode("utf-8").lower(): v.decode("utf-8") for k, v in scope.get("headers", []) } - return from_http(headers, body) + return from_http_event(HTTPMessage(headers=headers, body=body)) async def receive_body(receive): @@ -264,7 +266,7 @@ async def send_exception_cloudevent(send, status, message): # Check if send is a CloudEventSender with structured method if hasattr(send, 'structured'): - await send.structured(CloudEvent(attributes, data), status) + await send.structured(CloudEvent(attributes=attributes, data=data), status) else: # Fallback to plain HTTP error response if send is not a CloudEventSender logging.warning("send_exception_cloudevent called with non-CloudEventSender, falling back to HTTP response") @@ -293,13 +295,13 @@ async def __call__(self, event, status: int = 200): async def structured(self, event, status=200): """send as a structured cloudevent""" - headers, body = to_structured(event) - await self._send_encoded_cloudevent(headers, body, status) + msg = to_structured_event(event) + await self._send_encoded_cloudevent(msg.headers, msg.body, status) async def binary(self, event, status=200): """send as a binary cloudevent""" - headers, body = to_binary(event) - await self._send_encoded_cloudevent(headers, body, status) + msg = to_binary_event(event) + await self._send_encoded_cloudevent(msg.headers, msg.body, status) async def http(self, message): """Send a raw http response, bypassing the automatic cloudevent diff --git a/tests/test_cloudevent.py b/tests/test_cloudevent.py index 192ea628..8b9abb82 100644 --- a/tests/test_cloudevent.py +++ b/tests/test_cloudevent.py @@ -8,8 +8,8 @@ import uuid import pytest from func_python.cloudevent import serve -from cloudevents.conversion import to_structured -from cloudevents.http import CloudEvent +from cloudevents.core.bindings.http import to_structured_event +from cloudevents.core.v1.event import CloudEvent logging.basicConfig(level=logging.INFO) @@ -32,7 +32,7 @@ def test_static(): async def handle(scope, receive, send): # Ensure that the scope contains the CloudEvent if "event" not in scope: - await send(CloudEvent({}, {"message": "no event in scope"}), 500) + await send(CloudEvent(attributes={}, data={"message": "no event in scope"}), 500) attributes = { "type": "com.example.teststatic", @@ -43,7 +43,7 @@ async def handle(scope, receive, send): # The default send encodes the cloudevent using to_structured. # use send.binary to send it binary encoded, or send.http to use # the raw ASGI response object without CloudEvent middleware. - await send(CloudEvent(attributes, content)) + await send(CloudEvent(attributes=attributes, data=content)) # Test # Run async, this attempts to contact the running function and confirm the @@ -61,11 +61,11 @@ def test(): "source": "https://example.com/event-producer", } data = {"message": "test_static"} - headers, content = to_structured(CloudEvent(attributes, data)) + msg = to_structured_event(CloudEvent(attributes=attributes, data=data)) response = httpx.post( f"http://{LISTEN_ADDRESS}", - headers=headers, - content=content + headers=msg.headers, + content=msg.body ) # Assertions @@ -108,7 +108,7 @@ class MyFunction: async def handle(self, scope, receive, send): # Check if this is a CloudEvent if "event" not in scope: - await send(CloudEvent({},{"message": "no event in scope"}), 500) + await send(CloudEvent(attributes={}, data={"message": "no event in scope"}), 500) attributes = { "type": "com.example.testinstanced", @@ -119,7 +119,7 @@ async def handle(self, scope, receive, send): # The default send encodes the cloudevent using to_structured. # use send.binary to send it binary encoded, or send.http to use # the raw ASGI response object without CloudEvent middleware. - await send(CloudEvent(attributes, content)) + await send(CloudEvent(attributes=attributes, data=content)) def new(): return MyFunction() @@ -140,11 +140,11 @@ def test(): "source": "https://example.com/event-producer", } data = {"message": "test_instanced"} - headers, content = to_structured(CloudEvent(attributes, data)) + msg = to_structured_event(CloudEvent(attributes=attributes, data=data)) response = httpx.post( f"http://{LISTEN_ADDRESS}", - headers=headers, - content=content + headers=msg.headers, + content=msg.body ) # Assertions @@ -216,7 +216,7 @@ def test_non_cloudevent_get_request(): # Simple CloudEvent handler async def handle(scope, receive, send): # This should not be reached for non-CloudEvent requests - await send(CloudEvent({"type": "test", "source": "test"}, {"message": "Should not reach here"})) + await send(CloudEvent(attributes={"type": "test", "source": "test"}, data={"message": "Should not reach here"})) test_complete = threading.Event() test_results = {"success": False, "error": None} @@ -259,7 +259,7 @@ def test_non_cloudevent_post_request(): # Simple CloudEvent handler async def handle(scope, receive, send): # This should not be reached for non-CloudEvent requests - await send(CloudEvent({"type": "test", "source": "test"}, {"message": "Should not reach here"})) + await send(CloudEvent(attributes={"type": "test", "source": "test"}, data={"message": "Should not reach here"})) test_complete = threading.Event() test_results = {"success": False, "error": None}