From a0f9d20e6361f02824df6e3e574c4fd38094de5e Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Tue, 24 Jun 2025 11:03:46 -0700 Subject: [PATCH 01/12] Update CHANGELOG for 2.10.1 SR changes (#1999) * Update CHANGELOG for 2.10.1 SR changes * Minor cleanup * Move to 2.10.1 --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 634b1f7a9..86f135093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,16 @@ v2.10.1 is a maintenance release with the following fixes - Handled `None` value for optional `ctx` parameter in `ProtobufDeserializer` (#1939) - Handled `None` value for optional `ctx` parameter in `AvroDeserializer` (#1973) +- Handled ctx=None for AvroDeserializer and ProtobufDeserializer in __call__ (#1974) +- Fix possible NPE in CSFLE executor (#1980) +- Support for schema id in header (#1978) +- Raise an error if Protobuf deprecated format is specified (#1986) +- Implement Async Schema Registry client (#1965) confluent-kafka-python v2.10.1 is based on librdkafka v2.10.1, see the [librdkafka release notes](https://github.com/confluentinc/librdkafka/releases/tag/v2.10.1) for a complete list of changes, enhancements, fixes and upgrade considerations. - ## v2.10.0 v2.10.0 is a feature release with the following fixes and enhancements: From 46f4fab5c2cbc3856103b40730076d1f63cf7812 Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Tue, 1 Jul 2025 11:13:06 -0700 Subject: [PATCH 02/12] Add JSON Schema validation test; fix typo (#2003) * Add JSON Schema validation test; fix typo * Fix test * Fix flake8 --- .../schema_registry/_async/json_schema.py | 2 +- .../schema_registry/_sync/json_schema.py | 2 +- .../_async/test_json_serdes.py | 36 ++++++++++++++++++- .../schema_registry/_sync/test_json_serdes.py | 36 ++++++++++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/confluent_kafka/schema_registry/_async/json_schema.py b/src/confluent_kafka/schema_registry/_async/json_schema.py index 39dd2389f..8aa7bc17d 100644 --- a/src/confluent_kafka/schema_registry/_async/json_schema.py +++ b/src/confluent_kafka/schema_registry/_async/json_schema.py @@ -272,7 +272,7 @@ async def __init_impl( raise ValueError("schema.id.serializer must be callable") self._validate = conf_copy.pop('validate') - if not isinstance(self._normalize_schemas, bool): + if not isinstance(self._validate, bool): raise ValueError("validate must be a boolean value") if len(conf_copy) > 0: diff --git a/src/confluent_kafka/schema_registry/_sync/json_schema.py b/src/confluent_kafka/schema_registry/_sync/json_schema.py index ffd0b9e03..78ea5efd9 100644 --- a/src/confluent_kafka/schema_registry/_sync/json_schema.py +++ b/src/confluent_kafka/schema_registry/_sync/json_schema.py @@ -272,7 +272,7 @@ def __init_impl( raise ValueError("schema.id.serializer must be callable") self._validate = conf_copy.pop('validate') - if not isinstance(self._normalize_schemas, bool): + if not isinstance(self._validate, bool): raise ValueError("validate must be a boolean value") if len(conf_copy) > 0: diff --git a/tests/schema_registry/_async/test_json_serdes.py b/tests/schema_registry/_async/test_json_serdes.py index 50457deb2..69e984bb8 100644 --- a/tests/schema_registry/_async/test_json_serdes.py +++ b/tests/schema_registry/_async/test_json_serdes.py @@ -87,7 +87,7 @@ async def run_before_and_after_tests(tmpdir): async def test_json_basic_serialization(): conf = {'url': _BASE_URL} client = AsyncSchemaRegistryClient.new_client(conf) - ser_conf = {'auto.register.schemas': True} + ser_conf = {'auto.register.schemas': True, 'validate': True} obj = { 'intField': 123, 'doubleField': 45.67, @@ -121,6 +121,40 @@ async def test_json_basic_serialization(): assert obj == obj2 +async def test_json_basic_failing_validation(): + conf = {'url': _BASE_URL} + client = AsyncSchemaRegistryClient.new_client(conf) + ser_conf = {'auto.register.schemas': True, 'validate': True} + obj = { + 'intField': '123', + 'doubleField': 45.67, + 'stringField': 'hi', + 'booleanField': True, + 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), + } + schema = { + "type": "object", + "properties": { + "intField": {"type": "integer"}, + "doubleField": {"type": "number"}, + "stringField": { + "type": "string", + "confluent:tags": ["PII"] + }, + "booleanField": {"type": "boolean"}, + "bytesField": { + "type": "string", + "contentEncoding": "base64", + "confluent:tags": ["PII"] + } + } + } + ser = await AsyncJSONSerializer(json.dumps(schema), client, conf=ser_conf) + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + with pytest.raises(SerializationError): + await ser(obj, ser_ctx) + + async def test_json_guid_in_header(): conf = {'url': _BASE_URL} client = AsyncSchemaRegistryClient.new_client(conf) diff --git a/tests/schema_registry/_sync/test_json_serdes.py b/tests/schema_registry/_sync/test_json_serdes.py index bcaa3fe46..56b3714a2 100644 --- a/tests/schema_registry/_sync/test_json_serdes.py +++ b/tests/schema_registry/_sync/test_json_serdes.py @@ -87,7 +87,7 @@ def run_before_and_after_tests(tmpdir): def test_json_basic_serialization(): conf = {'url': _BASE_URL} client = SchemaRegistryClient.new_client(conf) - ser_conf = {'auto.register.schemas': True} + ser_conf = {'auto.register.schemas': True, 'validate': True} obj = { 'intField': 123, 'doubleField': 45.67, @@ -121,6 +121,40 @@ def test_json_basic_serialization(): assert obj == obj2 +def test_json_basic_failing_validation(): + conf = {'url': _BASE_URL} + client = SchemaRegistryClient.new_client(conf) + ser_conf = {'auto.register.schemas': True, 'validate': True} + obj = { + 'intField': '123', + 'doubleField': 45.67, + 'stringField': 'hi', + 'booleanField': True, + 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), + } + schema = { + "type": "object", + "properties": { + "intField": {"type": "integer"}, + "doubleField": {"type": "number"}, + "stringField": { + "type": "string", + "confluent:tags": ["PII"] + }, + "booleanField": {"type": "boolean"}, + "bytesField": { + "type": "string", + "contentEncoding": "base64", + "confluent:tags": ["PII"] + } + } + } + ser = JSONSerializer(json.dumps(schema), client, conf=ser_conf) + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + with pytest.raises(SerializationError): + ser(obj, ser_ctx) + + def test_json_guid_in_header(): conf = {'url': _BASE_URL} client = SchemaRegistryClient.new_client(conf) From 492e63badcebb54b79b0687baa1dddea9d1ff8bb Mon Sep 17 00:00:00 2001 From: Emanuele Sabellico Date: Thu, 3 Jul 2025 10:31:14 +0200 Subject: [PATCH 03/12] v2.11.0rc3 (#2005) --- .semaphore/semaphore.yml | 2 +- docs/conf.py | 2 +- examples/docker/Dockerfile.alpine | 2 +- pyproject.toml | 2 +- requirements/requirements-tests-install.txt | 2 +- src/confluent_kafka/src/confluent_kafka.h | 14 +++++++------- tests/trivup/trivup-0.12.10.tar.gz | Bin 34170 -> 0 bytes tests/trivup/trivup-0.13.0.tar.gz | Bin 0 -> 36268 bytes 8 files changed, 12 insertions(+), 12 deletions(-) delete mode 100644 tests/trivup/trivup-0.12.10.tar.gz create mode 100644 tests/trivup/trivup-0.13.0.tar.gz diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 3a6b27126..9d802ccb3 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -8,7 +8,7 @@ execution_time_limit: global_job_config: env_vars: - name: LIBRDKAFKA_VERSION - value: v2.10.1 + value: v2.11.0-RC3 prologue: commands: - checkout diff --git a/docs/conf.py b/docs/conf.py index d5e0f3ccc..4c383885e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ # built documents. # # The short X.Y version. -version = '2.10.1' +version = '2.11.0rc3' # The full version, including alpha/beta/rc tags. release = version ###################################################################### diff --git a/examples/docker/Dockerfile.alpine b/examples/docker/Dockerfile.alpine index a6bce3907..f5848f4b9 100644 --- a/examples/docker/Dockerfile.alpine +++ b/examples/docker/Dockerfile.alpine @@ -30,7 +30,7 @@ FROM alpine:3.12 COPY . /usr/src/confluent-kafka-python -ENV LIBRDKAFKA_VERSION="v2.10.1" +ENV LIBRDKAFKA_VERSION="v2.11.0-RC3" ENV KCAT_VERSION="master" ENV CKP_VERSION="master" diff --git a/pyproject.toml b/pyproject.toml index cd6106ff3..704b4021a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "confluent-kafka" -version = "2.10.1" +version = "2.11.0rc3" description = "Confluent's Python client for Apache Kafka" classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/requirements/requirements-tests-install.txt b/requirements/requirements-tests-install.txt index 622eaac3d..4a4e628f4 100644 --- a/requirements/requirements-tests-install.txt +++ b/requirements/requirements-tests-install.txt @@ -4,4 +4,4 @@ -r requirements-avro.txt -r requirements-protobuf.txt -r requirements-json.txt -tests/trivup/trivup-0.12.10.tar.gz \ No newline at end of file +tests/trivup/trivup-0.13.0.tar.gz \ No newline at end of file diff --git a/src/confluent_kafka/src/confluent_kafka.h b/src/confluent_kafka/src/confluent_kafka.h index 213bba7dc..f0a817e3b 100644 --- a/src/confluent_kafka/src/confluent_kafka.h +++ b/src/confluent_kafka/src/confluent_kafka.h @@ -42,8 +42,8 @@ * 0xMMmmRRPP * MM=major, mm=minor, RR=revision, PP=patchlevel (not used) */ -#define CFL_VERSION 0x020a0100 -#define CFL_VERSION_STR "2.10.1" +#define CFL_VERSION 0x020b0000 +#define CFL_VERSION_STR "2.11.0rc3" /** * Minimum required librdkafka version. This is checked both during @@ -51,19 +51,19 @@ * Make sure to keep the MIN_RD_KAFKA_VERSION, MIN_VER_ERRSTR and #error * defines and strings in sync. */ -#define MIN_RD_KAFKA_VERSION 0x020a01ff +#define MIN_RD_KAFKA_VERSION 0x020b00ff #ifdef __APPLE__ -#define MIN_VER_ERRSTR "confluent-kafka-python requires librdkafka v2.10.1 or later. Install the latest version of librdkafka from Homebrew by running `brew install librdkafka` or `brew upgrade librdkafka`" +#define MIN_VER_ERRSTR "confluent-kafka-python requires librdkafka v2.11.0 or later. Install the latest version of librdkafka from Homebrew by running `brew install librdkafka` or `brew upgrade librdkafka`" #else -#define MIN_VER_ERRSTR "confluent-kafka-python requires librdkafka v2.10.1 or later. Install the latest version of librdkafka from the Confluent repositories, see http://docs.confluent.io/current/installation.html" +#define MIN_VER_ERRSTR "confluent-kafka-python requires librdkafka v2.11.0 or later. Install the latest version of librdkafka from the Confluent repositories, see http://docs.confluent.io/current/installation.html" #endif #if RD_KAFKA_VERSION < MIN_RD_KAFKA_VERSION #ifdef __APPLE__ -#error "confluent-kafka-python requires librdkafka v2.10.1 or later. Install the latest version of librdkafka from Homebrew by running `brew install librdkafka` or `brew upgrade librdkafka`" +#error "confluent-kafka-python requires librdkafka v2.11.0 or later. Install the latest version of librdkafka from Homebrew by running `brew install librdkafka` or `brew upgrade librdkafka`" #else -#error "confluent-kafka-python requires librdkafka v2.10.1 or later. Install the latest version of librdkafka from the Confluent repositories, see http://docs.confluent.io/current/installation.html" +#error "confluent-kafka-python requires librdkafka v2.11.0 or later. Install the latest version of librdkafka from the Confluent repositories, see http://docs.confluent.io/current/installation.html" #endif #endif diff --git a/tests/trivup/trivup-0.12.10.tar.gz b/tests/trivup/trivup-0.12.10.tar.gz deleted file mode 100644 index 845a39b4b24da439195ab7d82f71e38f2260427e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34170 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 literal 0 HcmV?d00001 From 0d81d2954ffa2d707f3066cda1bccbbd6eb71e0a Mon Sep 17 00:00:00 2001 From: Emanuele Sabellico Date: Thu, 3 Jul 2025 18:56:01 +0200 Subject: [PATCH 04/12] v2.11.0 (#2006) --- .semaphore/semaphore.yml | 2 +- CHANGELOG.md | 11 ++++++++++- docs/conf.py | 2 +- examples/docker/Dockerfile.alpine | 2 +- pyproject.toml | 2 +- src/confluent_kafka/src/confluent_kafka.h | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 9d802ccb3..e119a2d98 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -8,7 +8,7 @@ execution_time_limit: global_job_config: env_vars: - name: LIBRDKAFKA_VERSION - value: v2.11.0-RC3 + value: v2.11.0 prologue: commands: - checkout diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f135093..b5f8c123b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,17 @@ # Confluent's Python client for Apache Kafka +## v2.11.0 + +v2.11.0 is a feature release with the following enhancements: + +confluent-kafka-python v2.11.0 is based on librdkafka v2.11.0, see the +[librdkafka release notes](https://github.com/confluentinc/librdkafka/releases/tag/v2.11.0) +for a complete list of changes, enhancements, fixes and upgrade considerations. + + ## v2.10.1 -v2.10.1 is a maintenance release with the following fixes +v2.10.1 is a maintenance release with the following fixes: - Handled `None` value for optional `ctx` parameter in `ProtobufDeserializer` (#1939) - Handled `None` value for optional `ctx` parameter in `AvroDeserializer` (#1973) diff --git a/docs/conf.py b/docs/conf.py index 4c383885e..decfdf63b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,7 @@ # built documents. # # The short X.Y version. -version = '2.11.0rc3' +version = '2.11.0' # The full version, including alpha/beta/rc tags. release = version ###################################################################### diff --git a/examples/docker/Dockerfile.alpine b/examples/docker/Dockerfile.alpine index f5848f4b9..08214da6a 100644 --- a/examples/docker/Dockerfile.alpine +++ b/examples/docker/Dockerfile.alpine @@ -30,7 +30,7 @@ FROM alpine:3.12 COPY . /usr/src/confluent-kafka-python -ENV LIBRDKAFKA_VERSION="v2.11.0-RC3" +ENV LIBRDKAFKA_VERSION="v2.11.0" ENV KCAT_VERSION="master" ENV CKP_VERSION="master" diff --git a/pyproject.toml b/pyproject.toml index 704b4021a..38e2604b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "confluent-kafka" -version = "2.11.0rc3" +version = "2.11.0" description = "Confluent's Python client for Apache Kafka" classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/src/confluent_kafka/src/confluent_kafka.h b/src/confluent_kafka/src/confluent_kafka.h index f0a817e3b..ac7474c9e 100644 --- a/src/confluent_kafka/src/confluent_kafka.h +++ b/src/confluent_kafka/src/confluent_kafka.h @@ -43,7 +43,7 @@ * MM=major, mm=minor, RR=revision, PP=patchlevel (not used) */ #define CFL_VERSION 0x020b0000 -#define CFL_VERSION_STR "2.11.0rc3" +#define CFL_VERSION_STR "2.11.0" /** * Minimum required librdkafka version. This is checked both during From a32d81b95939ece0c235a8197aaa534960720ebe Mon Sep 17 00:00:00 2001 From: Matthew Seal Date: Mon, 7 Jul 2025 14:00:53 -0700 Subject: [PATCH 05/12] Some of our tooling lands temporary files into the directory (#2002) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index edf60b535..19eb032cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build _build build-openssl dist +dest *~ \#* MANIFEST From c5ac241e2bcdf3fd0941883a4c799676d0dae0a7 Mon Sep 17 00:00:00 2001 From: Matthew Seal Date: Mon, 7 Jul 2025 14:01:35 -0700 Subject: [PATCH 06/12] Fixed non-tool path prefix file names for python conventions (#2000) --- examples/README.md | 2 +- examples/{eos-transactions.py => eos_transactions.py} | 2 +- tests/{test_KafkaError.py => test_kafka_error.py} | 0 tests/{test_SerializerError.py => test_serializer_error.py} | 0 tests/{test_TopicPartition.py => test_topic_partition.py} | 0 5 files changed, 2 insertions(+), 2 deletions(-) rename examples/{eos-transactions.py => eos_transactions.py} (99%) rename tests/{test_KafkaError.py => test_kafka_error.py} (100%) rename tests/{test_SerializerError.py => test_serializer_error.py} (100%) rename tests/{test_TopicPartition.py => test_topic_partition.py} (100%) diff --git a/examples/README.md b/examples/README.md index b68fb8886..fde034dab 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ The scripts in this directory provide various examples of using Confluent's Pyth * [asyncio_example.py](asyncio_example.py): AsyncIO webserver with Kafka producer. * [consumer.py](consumer.py): Read messages from a Kafka topic. * [producer.py](producer.py): Read lines from stdin and send them to a Kafka topic. -* [eos-transactions.py](eos-transactions.py): Transactional producer with exactly once semantics (EOS). +* [eos_transactions.py](eos_transactions.py): Transactional producer with exactly once semantics (EOS). * [avro_producer.py](avro_producer.py): Produce Avro serialized data using AvroSerializer. * [avro_consumer.py](avro_consumer.py): Read Avro serialized data using AvroDeserializer. * [json_producer.py](json_producer.py): Produce JSON serialized data using JSONSerializer. diff --git a/examples/eos-transactions.py b/examples/eos_transactions.py similarity index 99% rename from examples/eos-transactions.py rename to examples/eos_transactions.py index 6a60aee98..0cba2709a 100755 --- a/examples/eos-transactions.py +++ b/examples/eos_transactions.py @@ -109,7 +109,7 @@ def main(args): producer = Producer({ 'bootstrap.servers': brokers, - 'transactional.id': 'eos-transactions.py' + 'transactional.id': 'eos_transactions.py' }) # Initialize producer transaction. diff --git a/tests/test_KafkaError.py b/tests/test_kafka_error.py similarity index 100% rename from tests/test_KafkaError.py rename to tests/test_kafka_error.py diff --git a/tests/test_SerializerError.py b/tests/test_serializer_error.py similarity index 100% rename from tests/test_SerializerError.py rename to tests/test_serializer_error.py diff --git a/tests/test_TopicPartition.py b/tests/test_topic_partition.py similarity index 100% rename from tests/test_TopicPartition.py rename to tests/test_topic_partition.py From 8577ecbb719609b32232a35c13ac983c9b5c128c Mon Sep 17 00:00:00 2001 From: Roman Melnyk Date: Mon, 7 Jul 2025 23:25:26 +0200 Subject: [PATCH 07/12] Fix: Add version constraints to schemaregistry dependencies (#2007) This commit adds explicit version constraints to attrs and cachetools to ensure they are properly installed when using the schemaregistry extra. This fixes the "No module named 'attrs'" error reported in issue #1972. The version constraints are based on the versions that were successfully resolved in the user's pip-compile output. Co-authored-by: Robert Yokota --- requirements/requirements-schemaregistry.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/requirements-schemaregistry.txt b/requirements/requirements-schemaregistry.txt index 1534bd34a..f6e9d5afe 100644 --- a/requirements/requirements-schemaregistry.txt +++ b/requirements/requirements-schemaregistry.txt @@ -1,4 +1,4 @@ -attrs -cachetools +attrs>=21.2.0 +cachetools>=5.5.0 httpx>=0.26 authlib>=1.0.0 From 4bcf1a0fe7e9aa0a97a1d4cca78ad39feae8abf4 Mon Sep 17 00:00:00 2001 From: Corey Christous Date: Wed, 9 Jul 2025 13:57:24 -0400 Subject: [PATCH 08/12] remove explicit calls to pyenv (#2004) --- .semaphore/semaphore.yml | 8 -------- tools/wheels/build-wheels.sh | 3 +++ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index e119a2d98..139bbbe94 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -31,8 +31,6 @@ blocks: - name: Build commands: - sem-version python 3.11 - - PYTHON_VERSION=$(pyenv versions --bare | grep '^3.11' | head -n1) - - export PATH="$(pyenv root)/versions/$PYTHON_VERSION/bin:$PATH" - PIP_INSTALL_OPTIONS="--user" tools/wheels/build-wheels.sh "${LIBRDKAFKA_VERSION#v}" wheelhouse 2.16.2 - tar -czf wheelhouse-macOS-${ARCH}.tgz wheelhouse - artifact push workflow wheelhouse-macOS-${ARCH}.tgz --destination artifacts/wheels-${OS_NAME}-${ARCH}.tgz/ @@ -57,8 +55,6 @@ blocks: - name: Build commands: - sem-version python 3.11 - - PYTHON_VERSION=$(pyenv versions --bare | grep '^3.11' | head -n1) - - export PATH="$(pyenv root)/versions/$PYTHON_VERSION/bin:$PATH" - PIP_INSTALL_OPTIONS="--user" tools/wheels/build-wheels.sh "${LIBRDKAFKA_VERSION#v}" wheelhouse - tar -czf wheelhouse-macOS-${ARCH}-py313.tgz wheelhouse - artifact push workflow wheelhouse-macOS-${ARCH}-py313.tgz --destination artifacts/wheels-${OS_NAME}-${ARCH}-py313.tgz/ @@ -81,8 +77,6 @@ blocks: - name: Build commands: - sem-version python 3.11 - - PYTHON_VERSION=$(pyenv versions --bare | grep '^3.11' | head -n1) - - export PATH="$(pyenv root)/versions/$PYTHON_VERSION/bin:$PATH" - PIP_INSTALL_OPTIONS="--user" tools/wheels/build-wheels.sh "${LIBRDKAFKA_VERSION#v}" wheelhouse 2.16.2 - tar -czf wheelhouse-macOS-${ARCH}.tgz wheelhouse - artifact push workflow wheelhouse-macOS-${ARCH}.tgz --destination artifacts/wheels-${OS_NAME}-${ARCH}.tgz/ @@ -109,8 +103,6 @@ blocks: - name: Build commands: - sem-version python 3.11 - - PYTHON_VERSION=$(pyenv versions --bare | grep '^3.11' | head -n1) - - export PATH="$(pyenv root)/versions/$PYTHON_VERSION/bin:$PATH" - PIP_INSTALL_OPTIONS="--user" tools/wheels/build-wheels.sh "${LIBRDKAFKA_VERSION#v}" wheelhouse - tar -czf wheelhouse-macOS-${ARCH}-py313.tgz wheelhouse - artifact push workflow wheelhouse-macOS-${ARCH}-py313.tgz --destination artifacts/wheels-${OS_NAME}-${ARCH}-py313.tgz/ diff --git a/tools/wheels/build-wheels.sh b/tools/wheels/build-wheels.sh index 96b6e5a6d..d9af094c9 100755 --- a/tools/wheels/build-wheels.sh +++ b/tools/wheels/build-wheels.sh @@ -53,6 +53,9 @@ $this_dir/install-librdkafka.sh $librdkafka_version dest install_pkgs=cibuildwheel==$cibuildwheel_version +# Ensure we have pip installed if using uv +uv pip install pip || true + python3 -m pip install ${PIP_INSTALL_OPTS} $install_pkgs || pip3 install ${PIP_INSTALL_OPTS} $install_pkgs From 138517e8fdd1353146801fa72871d36a6ac5ae35 Mon Sep 17 00:00:00 2001 From: Emanuele Sabellico Date: Thu, 10 Jul 2025 17:20:59 +0200 Subject: [PATCH 09/12] Add MSeal to code owners (#2009) --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 62dc1ad94..198c87908 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # See go/codeowners - automatically generated for confluentinc/confluent-kafka-python: -* @confluentinc/clients @confluentinc/data-governance +* @confluentinc/clients @confluentinc/data-governance @MSeal From 02b1e6f50d9cf8d7a8f2f4c06fbe58f81c6d06af Mon Sep 17 00:00:00 2001 From: Robert Yokota Date: Fri, 18 Jul 2025 10:26:53 -0700 Subject: [PATCH 10/12] Refactor encryption executor (#2011) --- .../schema_registry/_async/avro.py | 18 +++- .../schema_registry/_async/json_schema.py | 28 ++++-- .../schema_registry/_async/protobuf.py | 21 +++- .../schema_registry/_async/serde.py | 44 +++++++-- .../schema_registry/_sync/avro.py | 18 +++- .../schema_registry/_sync/json_schema.py | 28 ++++-- .../schema_registry/_sync/protobuf.py | 21 +++- .../schema_registry/_sync/serde.py | 44 +++++++-- .../common/schema_registry_client.py | 30 +++++- .../rules/encryption/encrypt_executor.py | 87 ++++++++++++---- .../_async/test_avro_serdes.py | 98 +++++++++++++++---- .../_async/test_json_serdes.py | 80 +++++++++++++-- .../_async/test_proto_serdes.py | 60 +++++++++++- .../schema_registry/_sync/test_avro_serdes.py | 98 +++++++++++++++---- .../schema_registry/_sync/test_json_serdes.py | 80 +++++++++++++-- .../_sync/test_proto_serdes.py | 60 +++++++++++- 16 files changed, 698 insertions(+), 117 deletions(-) diff --git a/src/confluent_kafka/schema_registry/_async/avro.py b/src/confluent_kafka/schema_registry/_async/avro.py index 91016f1ec..fc8ff0749 100644 --- a/src/confluent_kafka/schema_registry/_async/avro.py +++ b/src/confluent_kafka/schema_registry/_async/avro.py @@ -14,7 +14,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import io from json import loads from typing import Dict, Union, Optional, Callable @@ -29,6 +29,8 @@ AsyncSchemaRegistryClient, prefix_schema_id_serializer, dual_schema_id_deserializer) +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.serialization import (SerializationError, SerializationContext) from confluent_kafka.schema_registry.rule_registry import RuleRegistry @@ -348,8 +350,14 @@ def field_transformer(rule_ctx, field_transform, msg): return ( # noqa: E731 with _ContextStringIO() as fo: # write the record to the rest of the buffer schemaless_writer(fo, parsed_schema, value) + buffer = fo.getvalue() + + if latest_schema is not None: + buffer = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.WRITE, + None, latest_schema.schema, buffer, None, None) - return self._schema_id_serializer(fo.getvalue(), ctx, self._schema_id) + return self._schema_id_serializer(buffer, ctx, self._schema_id) async def _get_parsed_schema(self, schema: Schema) -> AvroSchema: parsed_schema = self._parsed_schemas.get_parsed_schema(schema) @@ -557,6 +565,12 @@ async def __deserialize( if subject is not None: latest_schema = await self._get_reader_schema(subject) + payload = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.READ, + None, writer_schema_raw, payload, None, None) + if isinstance(payload, bytes): + payload = io.BytesIO(payload) + if latest_schema is not None: migrations = await self._get_migrations(subject, writer_schema_raw, latest_schema, None) reader_schema_raw = latest_schema.schema diff --git a/src/confluent_kafka/schema_registry/_async/json_schema.py b/src/confluent_kafka/schema_registry/_async/json_schema.py index 8aa7bc17d..99c1f7d59 100644 --- a/src/confluent_kafka/schema_registry/_async/json_schema.py +++ b/src/confluent_kafka/schema_registry/_async/json_schema.py @@ -14,7 +14,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import io import json from typing import Union, Optional, Tuple, Callable @@ -33,6 +33,8 @@ from confluent_kafka.schema_registry.common.json_schema import ( DEFAULT_SPEC, JsonSchema, _retrieve_via_httpx, transform, _ContextStringIO, JSON_TYPE ) +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.schema_registry.rule_registry import RuleRegistry from confluent_kafka.schema_registry.serde import AsyncBaseSerializer, AsyncBaseDeserializer, \ ParsedSchemaCache, SchemaId @@ -374,8 +376,14 @@ def field_transformer(rule_ctx, field_transform, msg): return ( # noqa: E731 if isinstance(encoded_value, str): encoded_value = encoded_value.encode("utf8") fo.write(encoded_value) + buffer = fo.getvalue() + + if latest_schema is not None: + buffer = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.WRITE, + None, latest_schema.schema, buffer, None, None) - return self._schema_id_serializer(fo.getvalue(), ctx, self._schema_id) + return self._schema_id_serializer(buffer, ctx, self._schema_id) async def _get_parsed_schema(self, schema: Schema) -> Tuple[Optional[JsonSchema], Optional[Registry]]: if schema is None: @@ -552,9 +560,9 @@ async def __init_impl( __init__ = __init_impl def __call__(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: - return self.__serialize(data, ctx) + return self.__deserialize(data, ctx) - async def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: + async def __deserialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: """ Deserialize a JSON encoded record with Confluent Schema Registry framing to a dict, or object instance according to from_dict if from_dict is specified. @@ -583,9 +591,6 @@ async def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = N schema_id = SchemaId(JSON_TYPE) payload = self._schema_id_deserializer(data, ctx, schema_id) - # JSON documents are self-describing; no need to query schema - obj_dict = self._json_decode(payload.read()) - if self._registry is not None: writer_schema_raw = await self._get_writer_schema(schema_id, subject) writer_schema, writer_ref_registry = await self._get_parsed_schema(writer_schema_raw) @@ -597,6 +602,15 @@ async def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = N writer_schema_raw = None writer_schema, writer_ref_registry = None, None + payload = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.READ, + None, writer_schema_raw, payload, None, None) + if isinstance(payload, bytes): + payload = io.BytesIO(payload) + + # JSON documents are self-describing; no need to query schema + obj_dict = self._json_decode(payload.read()) + if latest_schema is not None: migrations = await self._get_migrations(subject, writer_schema_raw, latest_schema, None) reader_schema_raw = latest_schema.schema diff --git a/src/confluent_kafka/schema_registry/_async/protobuf.py b/src/confluent_kafka/schema_registry/_async/protobuf.py index 20ecaa57d..f7d1dc8b3 100644 --- a/src/confluent_kafka/schema_registry/_async/protobuf.py +++ b/src/confluent_kafka/schema_registry/_async/protobuf.py @@ -27,6 +27,8 @@ from confluent_kafka.schema_registry import (reference_subject_name_strategy, topic_subject_name_strategy, prefix_schema_id_serializer, dual_schema_id_deserializer) +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.schema_registry.schema_registry_client import AsyncSchemaRegistryClient from confluent_kafka.schema_registry.common.protobuf import _bytes, _create_index_array, \ _init_pool, _is_builtin, _schema_to_str, _str_to_proto, transform, _ContextStringIO, PROTOBUF_TYPE @@ -426,7 +428,14 @@ def field_transformer(rule_ctx, field_transform, msg): return ( # noqa: E731 with _ContextStringIO() as fo: fo.write(message.SerializeToString()) self._schema_id.message_indexes = self._index_array - return self._schema_id_serializer(fo.getvalue(), ctx, self._schema_id) + buffer = fo.getvalue() + + if latest_schema is not None: + buffer = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.WRITE, + None, latest_schema.schema, buffer, None, None) + + return self._schema_id_serializer(buffer, ctx, self._schema_id) async def _get_parsed_schema(self, schema: Schema) -> Tuple[descriptor_pb2.FileDescriptorProto, DescriptorPool]: result = self._parsed_schemas.get_parsed_schema(schema) @@ -549,9 +558,9 @@ async def __init_impl( __init__ = __init_impl def __call__(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: - return self.__serialize(data, ctx) + return self.__deserialize(data, ctx) - async def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: + async def __deserialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: """ Deserialize a serialized protobuf message with Confluent Schema Registry framing. @@ -596,6 +605,12 @@ async def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = N writer_schema_raw = None writer_schema = None + payload = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.READ, + None, writer_schema_raw, payload, None, None) + if isinstance(payload, bytes): + payload = io.BytesIO(payload) + if latest_schema is not None: migrations = await self._get_migrations(subject, writer_schema_raw, latest_schema, None) reader_schema_raw = latest_schema.schema diff --git a/src/confluent_kafka/schema_registry/_async/serde.py b/src/confluent_kafka/schema_registry/_async/serde.py index 792761430..5b208c39c 100644 --- a/src/confluent_kafka/schema_registry/_async/serde.py +++ b/src/confluent_kafka/schema_registry/_async/serde.py @@ -20,6 +20,8 @@ from typing import List, Optional, Set, Dict, Any from confluent_kafka.schema_registry import RegisteredSchema +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.schema_registry.common.serde import ErrorAction, \ FieldTransformer, Migration, NoneAction, RuleAction, \ RuleConditionError, RuleContext, RuleError, SchemaId @@ -59,6 +61,17 @@ def _execute_rules( source: Optional[Schema], target: Optional[Schema], message: Any, inline_tags: Optional[Dict[str, Set[str]]], field_transformer: Optional[FieldTransformer] + ) -> Any: + return self._execute_rules_with_phase( + ser_ctx, subject, RulePhase.DOMAIN, rule_mode, + source, target, message, inline_tags, field_transformer) + + def _execute_rules_with_phase( + self, ser_ctx: SerializationContext, subject: str, + rule_phase: RulePhase, rule_mode: RuleMode, + source: Optional[Schema], target: Optional[Schema], + message: Any, inline_tags: Optional[Dict[str, Set[str]]], + field_transformer: Optional[FieldTransformer] ) -> Any: if message is None or target is None: return message @@ -73,7 +86,10 @@ def _execute_rules( rules.reverse() else: if target is not None and target.rule_set is not None: - rules = target.rule_set.domain_rules + if rule_phase == RulePhase.ENCODING: + rules = target.rule_set.encoding_rules + else: + rules = target.rule_set.domain_rules if rule_mode == RuleMode.READ: # Execute read rules in reverse order for symmetry rules = rules[:] if rules else [] @@ -197,19 +213,25 @@ async def _get_writer_schema( else: raise SerializationError("Schema ID or GUID is not set") - def _has_rules(self, rule_set: RuleSet, mode: RuleMode) -> bool: + def _has_rules(self, rule_set: RuleSet, phase: RulePhase, mode: RuleMode) -> bool: if rule_set is None: return False + if phase == RulePhase.MIGRATION: + rules = rule_set.migration_rules + elif phase == RulePhase.DOMAIN: + rules = rule_set.domain_rules + elif phase == RulePhase.ENCODING: + rules = rule_set.encoding_rules if mode in (RuleMode.UPGRADE, RuleMode.DOWNGRADE): return any(rule.mode == mode or rule.mode == RuleMode.UPDOWN - for rule in rule_set.migration_rules or []) + for rule in rules or []) elif mode == RuleMode.UPDOWN: - return any(rule.mode == mode for rule in rule_set.migration_rules or []) + return any(rule.mode == mode for rule in rules or []) elif mode in (RuleMode.WRITE, RuleMode.READ): return any(rule.mode == mode or rule.mode == RuleMode.WRITEREAD - for rule in rule_set.domain_rules or []) + for rule in rules or []) elif mode == RuleMode.WRITEREAD: - return any(rule.mode == mode for rule in rule_set.migration_rules or []) + return any(rule.mode == mode for rule in rules or []) return False async def _get_migrations( @@ -235,7 +257,8 @@ async def _get_migrations( if i == 0: previous = version continue - if version.schema.rule_set is not None and self._has_rules(version.schema.rule_set, migration_mode): + if version.schema.rule_set is not None and self._has_rules( + version.schema.rule_set, RulePhase.MIGRATION, migration_mode): if migration_mode == RuleMode.UPGRADE: migration = Migration(migration_mode, previous, version) else: @@ -265,7 +288,8 @@ def _execute_migrations( migrations: List[Migration], message: Any ) -> Any: for migration in migrations: - message = self._execute_rules(ser_ctx, subject, migration.rule_mode, - migration.source.schema, migration.target.schema, - message, None, None) + message = self._execute_rules_with_phase( + ser_ctx, subject, RulePhase.MIGRATION, migration.rule_mode, + migration.source.schema, migration.target.schema, + message, None, None) return message diff --git a/src/confluent_kafka/schema_registry/_sync/avro.py b/src/confluent_kafka/schema_registry/_sync/avro.py index e1e2ac915..78e7dd8ea 100644 --- a/src/confluent_kafka/schema_registry/_sync/avro.py +++ b/src/confluent_kafka/schema_registry/_sync/avro.py @@ -14,7 +14,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import io from json import loads from typing import Dict, Union, Optional, Callable @@ -29,6 +29,8 @@ SchemaRegistryClient, prefix_schema_id_serializer, dual_schema_id_deserializer) +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.serialization import (SerializationError, SerializationContext) from confluent_kafka.schema_registry.rule_registry import RuleRegistry @@ -348,8 +350,14 @@ def field_transformer(rule_ctx, field_transform, msg): return ( # noqa: E731 with _ContextStringIO() as fo: # write the record to the rest of the buffer schemaless_writer(fo, parsed_schema, value) + buffer = fo.getvalue() + + if latest_schema is not None: + buffer = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.WRITE, + None, latest_schema.schema, buffer, None, None) - return self._schema_id_serializer(fo.getvalue(), ctx, self._schema_id) + return self._schema_id_serializer(buffer, ctx, self._schema_id) def _get_parsed_schema(self, schema: Schema) -> AvroSchema: parsed_schema = self._parsed_schemas.get_parsed_schema(schema) @@ -557,6 +565,12 @@ def __deserialize( if subject is not None: latest_schema = self._get_reader_schema(subject) + payload = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.READ, + None, writer_schema_raw, payload, None, None) + if isinstance(payload, bytes): + payload = io.BytesIO(payload) + if latest_schema is not None: migrations = self._get_migrations(subject, writer_schema_raw, latest_schema, None) reader_schema_raw = latest_schema.schema diff --git a/src/confluent_kafka/schema_registry/_sync/json_schema.py b/src/confluent_kafka/schema_registry/_sync/json_schema.py index 78ea5efd9..88da6322c 100644 --- a/src/confluent_kafka/schema_registry/_sync/json_schema.py +++ b/src/confluent_kafka/schema_registry/_sync/json_schema.py @@ -14,7 +14,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - +import io import json from typing import Union, Optional, Tuple, Callable @@ -33,6 +33,8 @@ from confluent_kafka.schema_registry.common.json_schema import ( DEFAULT_SPEC, JsonSchema, _retrieve_via_httpx, transform, _ContextStringIO, JSON_TYPE ) +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.schema_registry.rule_registry import RuleRegistry from confluent_kafka.schema_registry.serde import BaseSerializer, BaseDeserializer, \ ParsedSchemaCache, SchemaId @@ -374,8 +376,14 @@ def field_transformer(rule_ctx, field_transform, msg): return ( # noqa: E731 if isinstance(encoded_value, str): encoded_value = encoded_value.encode("utf8") fo.write(encoded_value) + buffer = fo.getvalue() + + if latest_schema is not None: + buffer = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.WRITE, + None, latest_schema.schema, buffer, None, None) - return self._schema_id_serializer(fo.getvalue(), ctx, self._schema_id) + return self._schema_id_serializer(buffer, ctx, self._schema_id) def _get_parsed_schema(self, schema: Schema) -> Tuple[Optional[JsonSchema], Optional[Registry]]: if schema is None: @@ -552,9 +560,9 @@ def __init_impl( __init__ = __init_impl def __call__(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: - return self.__serialize(data, ctx) + return self.__deserialize(data, ctx) - def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: + def __deserialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: """ Deserialize a JSON encoded record with Confluent Schema Registry framing to a dict, or object instance according to from_dict if from_dict is specified. @@ -583,9 +591,6 @@ def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) - schema_id = SchemaId(JSON_TYPE) payload = self._schema_id_deserializer(data, ctx, schema_id) - # JSON documents are self-describing; no need to query schema - obj_dict = self._json_decode(payload.read()) - if self._registry is not None: writer_schema_raw = self._get_writer_schema(schema_id, subject) writer_schema, writer_ref_registry = self._get_parsed_schema(writer_schema_raw) @@ -597,6 +602,15 @@ def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) - writer_schema_raw = None writer_schema, writer_ref_registry = None, None + payload = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.READ, + None, writer_schema_raw, payload, None, None) + if isinstance(payload, bytes): + payload = io.BytesIO(payload) + + # JSON documents are self-describing; no need to query schema + obj_dict = self._json_decode(payload.read()) + if latest_schema is not None: migrations = self._get_migrations(subject, writer_schema_raw, latest_schema, None) reader_schema_raw = latest_schema.schema diff --git a/src/confluent_kafka/schema_registry/_sync/protobuf.py b/src/confluent_kafka/schema_registry/_sync/protobuf.py index 08c64bf44..f202f0e99 100644 --- a/src/confluent_kafka/schema_registry/_sync/protobuf.py +++ b/src/confluent_kafka/schema_registry/_sync/protobuf.py @@ -27,6 +27,8 @@ from confluent_kafka.schema_registry import (reference_subject_name_strategy, topic_subject_name_strategy, prefix_schema_id_serializer, dual_schema_id_deserializer) +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.schema_registry.schema_registry_client import SchemaRegistryClient from confluent_kafka.schema_registry.common.protobuf import _bytes, _create_index_array, \ _init_pool, _is_builtin, _schema_to_str, _str_to_proto, transform, _ContextStringIO, PROTOBUF_TYPE @@ -426,7 +428,14 @@ def field_transformer(rule_ctx, field_transform, msg): return ( # noqa: E731 with _ContextStringIO() as fo: fo.write(message.SerializeToString()) self._schema_id.message_indexes = self._index_array - return self._schema_id_serializer(fo.getvalue(), ctx, self._schema_id) + buffer = fo.getvalue() + + if latest_schema is not None: + buffer = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.WRITE, + None, latest_schema.schema, buffer, None, None) + + return self._schema_id_serializer(buffer, ctx, self._schema_id) def _get_parsed_schema(self, schema: Schema) -> Tuple[descriptor_pb2.FileDescriptorProto, DescriptorPool]: result = self._parsed_schemas.get_parsed_schema(schema) @@ -549,9 +558,9 @@ def __init_impl( __init__ = __init_impl def __call__(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: - return self.__serialize(data, ctx) + return self.__deserialize(data, ctx) - def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: + def __deserialize(self, data: bytes, ctx: Optional[SerializationContext] = None) -> Optional[bytes]: """ Deserialize a serialized protobuf message with Confluent Schema Registry framing. @@ -596,6 +605,12 @@ def __serialize(self, data: bytes, ctx: Optional[SerializationContext] = None) - writer_schema_raw = None writer_schema = None + payload = self._execute_rules_with_phase( + ctx, subject, RulePhase.ENCODING, RuleMode.READ, + None, writer_schema_raw, payload, None, None) + if isinstance(payload, bytes): + payload = io.BytesIO(payload) + if latest_schema is not None: migrations = self._get_migrations(subject, writer_schema_raw, latest_schema, None) reader_schema_raw = latest_schema.schema diff --git a/src/confluent_kafka/schema_registry/_sync/serde.py b/src/confluent_kafka/schema_registry/_sync/serde.py index f0512f872..df192ed5e 100644 --- a/src/confluent_kafka/schema_registry/_sync/serde.py +++ b/src/confluent_kafka/schema_registry/_sync/serde.py @@ -20,6 +20,8 @@ from typing import List, Optional, Set, Dict, Any from confluent_kafka.schema_registry import RegisteredSchema +from confluent_kafka.schema_registry.common.schema_registry_client import \ + RulePhase from confluent_kafka.schema_registry.common.serde import ErrorAction, \ FieldTransformer, Migration, NoneAction, RuleAction, \ RuleConditionError, RuleContext, RuleError, SchemaId @@ -59,6 +61,17 @@ def _execute_rules( source: Optional[Schema], target: Optional[Schema], message: Any, inline_tags: Optional[Dict[str, Set[str]]], field_transformer: Optional[FieldTransformer] + ) -> Any: + return self._execute_rules_with_phase( + ser_ctx, subject, RulePhase.DOMAIN, rule_mode, + source, target, message, inline_tags, field_transformer) + + def _execute_rules_with_phase( + self, ser_ctx: SerializationContext, subject: str, + rule_phase: RulePhase, rule_mode: RuleMode, + source: Optional[Schema], target: Optional[Schema], + message: Any, inline_tags: Optional[Dict[str, Set[str]]], + field_transformer: Optional[FieldTransformer] ) -> Any: if message is None or target is None: return message @@ -73,7 +86,10 @@ def _execute_rules( rules.reverse() else: if target is not None and target.rule_set is not None: - rules = target.rule_set.domain_rules + if rule_phase == RulePhase.ENCODING: + rules = target.rule_set.encoding_rules + else: + rules = target.rule_set.domain_rules if rule_mode == RuleMode.READ: # Execute read rules in reverse order for symmetry rules = rules[:] if rules else [] @@ -197,19 +213,25 @@ def _get_writer_schema( else: raise SerializationError("Schema ID or GUID is not set") - def _has_rules(self, rule_set: RuleSet, mode: RuleMode) -> bool: + def _has_rules(self, rule_set: RuleSet, phase: RulePhase, mode: RuleMode) -> bool: if rule_set is None: return False + if phase == RulePhase.MIGRATION: + rules = rule_set.migration_rules + elif phase == RulePhase.DOMAIN: + rules = rule_set.domain_rules + elif phase == RulePhase.ENCODING: + rules = rule_set.encoding_rules if mode in (RuleMode.UPGRADE, RuleMode.DOWNGRADE): return any(rule.mode == mode or rule.mode == RuleMode.UPDOWN - for rule in rule_set.migration_rules or []) + for rule in rules or []) elif mode == RuleMode.UPDOWN: - return any(rule.mode == mode for rule in rule_set.migration_rules or []) + return any(rule.mode == mode for rule in rules or []) elif mode in (RuleMode.WRITE, RuleMode.READ): return any(rule.mode == mode or rule.mode == RuleMode.WRITEREAD - for rule in rule_set.domain_rules or []) + for rule in rules or []) elif mode == RuleMode.WRITEREAD: - return any(rule.mode == mode for rule in rule_set.migration_rules or []) + return any(rule.mode == mode for rule in rules or []) return False def _get_migrations( @@ -235,7 +257,8 @@ def _get_migrations( if i == 0: previous = version continue - if version.schema.rule_set is not None and self._has_rules(version.schema.rule_set, migration_mode): + if version.schema.rule_set is not None and self._has_rules( + version.schema.rule_set, RulePhase.MIGRATION, migration_mode): if migration_mode == RuleMode.UPGRADE: migration = Migration(migration_mode, previous, version) else: @@ -265,7 +288,8 @@ def _execute_migrations( migrations: List[Migration], message: Any ) -> Any: for migration in migrations: - message = self._execute_rules(ser_ctx, subject, migration.rule_mode, - migration.source.schema, migration.target.schema, - message, None, None) + message = self._execute_rules_with_phase( + ser_ctx, subject, RulePhase.MIGRATION, migration.rule_mode, + migration.source.schema, migration.target.schema, + message, None, None) return message diff --git a/src/confluent_kafka/schema_registry/common/schema_registry_client.py b/src/confluent_kafka/schema_registry/common/schema_registry_client.py index edbd38d02..27f9d946a 100644 --- a/src/confluent_kafka/schema_registry/common/schema_registry_client.py +++ b/src/confluent_kafka/schema_registry/common/schema_registry_client.py @@ -312,6 +312,15 @@ def __str__(self) -> str: return str(self.value) +class RulePhase(str, Enum): + MIGRATION = "MIGRATION" + DOMAIN = "DOMAIN" + ENCODING = "ENCODING" + + def __str__(self) -> str: + return str(self.value) + + class RuleMode(str, Enum): UPGRADE = "UPGRADE" DOWNGRADE = "DOWNGRADE" @@ -471,6 +480,7 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: class RuleSet: migration_rules: Optional[List["Rule"]] = _attrs_field(hash=False) domain_rules: Optional[List["Rule"]] = _attrs_field(hash=False) + encoding_rules: Optional[List["Rule"]] = _attrs_field(hash=False, default=None) def to_dict(self) -> Dict[str, Any]: _migration_rules: Optional[List[Dict[str, Any]]] = None @@ -487,12 +497,21 @@ def to_dict(self) -> Dict[str, Any]: domain_rules_item = domain_rules_item_data.to_dict() _domain_rules.append(domain_rules_item) + _encoding_rules: Optional[List[Dict[str, Any]]] = None + if self.encoding_rules is not None: + _encoding_rules = [] + for encoding_rules_item_data in self.encoding_rules: + encoding_rules_item = encoding_rules_item_data.to_dict() + _encoding_rules.append(encoding_rules_item) + field_dict: Dict[str, Any] = {} field_dict.update({}) if _migration_rules is not None: field_dict["migrationRules"] = _migration_rules if _domain_rules is not None: field_dict["domainRules"] = _domain_rules + if _encoding_rules is not None: + field_dict["encodingRules"] = _encoding_rules return field_dict @@ -511,15 +530,24 @@ def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T: domain_rules_item = Rule.from_dict(domain_rules_item_data) domain_rules.append(domain_rules_item) + encoding_rules = [] + _encoding_rules = d.pop("encodingRules", None) + for encoding_rules_item_data in _encoding_rules or []: + encoding_rules_item = Rule.from_dict(encoding_rules_item_data) + encoding_rules.append(encoding_rules_item) + rule_set = cls( migration_rules=migration_rules, domain_rules=domain_rules, + encoding_rules=encoding_rules, ) return rule_set def __hash__(self): - return hash(frozenset((self.migration_rules or []) + (self.domain_rules or []))) + return hash(frozenset((self.migration_rules or []) + + (self.domain_rules or []) + + (self.encoding_rules or []))) @_attrs_define diff --git a/src/confluent_kafka/schema_registry/rules/encryption/encrypt_executor.py b/src/confluent_kafka/schema_registry/rules/encryption/encrypt_executor.py index cb480f145..8a05e3077 100644 --- a/src/confluent_kafka/schema_registry/rules/encryption/encrypt_executor.py +++ b/src/confluent_kafka/schema_registry/rules/encryption/encrypt_executor.py @@ -13,6 +13,7 @@ # limitations under the License. import base64 +import io import logging import time from typing import Optional, Tuple, Any @@ -30,8 +31,8 @@ from confluent_kafka.schema_registry.rules.encryption.kms_driver_registry import \ get_kms_driver, KmsDriver from confluent_kafka.schema_registry.serde import RuleContext, \ - FieldRuleExecutor, FieldTransform, RuleError, FieldContext, FieldType - + RuleError, RuleExecutor, FieldType, FieldRuleExecutor, FieldTransform, \ + FieldContext log = logging.getLogger(__name__) @@ -53,7 +54,7 @@ def now(self) -> int: return int(round(time.time() * 1000)) -class FieldEncryptionExecutor(FieldRuleExecutor): +class EncryptionExecutor(RuleExecutor): def __init__(self, clock: Clock = Clock()): self.client = None @@ -81,15 +82,19 @@ def configure(self, client_conf: dict, rule_conf: dict): self.config = rule_conf if rule_conf else {} def type(self) -> str: - return "ENCRYPT" + return "ENCRYPT_PAYLOAD" - def new_transform(self, ctx: RuleContext) -> FieldTransform: + def transform(self, ctx: RuleContext, message: Any) -> Any: + executor = self.new_transform(ctx) + return executor.transform(ctx, FieldType.BYTES, message) + + def new_transform(self, ctx: RuleContext) -> 'EncryptionExecutorTransform': cryptor = self._get_cryptor(ctx) kek_name = self._get_kek_name(ctx) dek_expiry_days = self._get_dek_expiry_days(ctx) - transform = FieldEncryptionExecutorTransform( + transform = EncryptionExecutorTransform( self, cryptor, kek_name, dek_expiry_days) - return transform.transform + return transform def close(self): if self.client is not None: @@ -125,11 +130,11 @@ def _get_dek_expiry_days(self, ctx: RuleContext) -> int: @classmethod def register(cls): - RuleRegistry.register_rule_executor(FieldEncryptionExecutor()) + RuleRegistry.register_rule_executor(EncryptionExecutor()) @classmethod - def register_with_clock(cls, clock: Clock) -> 'FieldEncryptionExecutor': - executor = FieldEncryptionExecutor(clock) + def register_with_clock(cls, clock: Clock) -> 'EncryptionExecutor': + executor = EncryptionExecutor(clock) RuleRegistry.register_rule_executor(executor) return executor @@ -191,9 +196,9 @@ def decrypt(self, dek: bytes, ciphertext: bytes, associated_data: bytes) -> byte return primitive.decrypt(ciphertext, associated_data) -class FieldEncryptionExecutorTransform(object): +class EncryptionExecutorTransform(object): - def __init__(self, executor: FieldEncryptionExecutor, cryptor: Cryptor, kek_name: str, dek_expiry_days: int): + def __init__(self, executor: EncryptionExecutor, cryptor: Cryptor, kek_name: str, dek_expiry_days: int): self._executor = executor self._cryptor = cryptor self._kek_name = kek_name @@ -341,13 +346,13 @@ def _is_expired(self, ctx: RuleContext, dek: Optional[Dek]) -> bool: and dek is not None and (now - dek.ts) / MILLIS_IN_DAY > self._dek_expiry_days) - def transform(self, ctx: RuleContext, field_ctx: FieldContext, field_value: Any) -> Any: + def transform(self, ctx: RuleContext, field_type: FieldType, field_value: Any) -> Any: if field_value is None: return None if ctx.rule_mode == RuleMode.WRITE: - plaintext = self._to_bytes(field_ctx.field_type, field_value) + plaintext = self._to_bytes(field_type, field_value) if plaintext is None: - raise RuleError(f"type {field_ctx.field_type} not supported for encryption") + raise RuleError(f"type {field_type} not supported for encryption") version = None if self._is_dek_rotated(): version = -1 @@ -356,15 +361,15 @@ def transform(self, ctx: RuleContext, field_ctx: FieldContext, field_value: Any) ciphertext = self._cryptor.encrypt(key_material_bytes, plaintext, Cryptor.EMPTY_AAD) if self._is_dek_rotated(): ciphertext = self._prefix_version(dek.version, ciphertext) - if field_ctx.field_type == FieldType.STRING: + if field_type == FieldType.STRING: return base64.b64encode(ciphertext).decode("utf-8") else: - return self._to_object(field_ctx.field_type, ciphertext) + return self._to_object(field_type, ciphertext) elif ctx.rule_mode == RuleMode.READ: - if field_ctx.field_type == FieldType.STRING: + if field_type == FieldType.STRING: ciphertext = base64.b64decode(field_value) else: - ciphertext = self._to_bytes(field_ctx.field_type, field_value) + ciphertext = self._to_bytes(field_type, field_value) if ciphertext is None: return field_value @@ -376,7 +381,7 @@ def transform(self, ctx: RuleContext, field_ctx: FieldContext, field_value: Any) dek = self._get_or_create_dek(ctx, version) key_material_bytes = dek.get_key_material_bytes() plaintext = self._cryptor.decrypt(key_material_bytes, ciphertext, Cryptor.EMPTY_AAD) - return self._to_object(field_ctx.field_type, plaintext) + return self._to_object(field_type, plaintext) else: raise RuleError(f"unsupported rule mode {ctx.rule_mode}") @@ -393,6 +398,8 @@ def _to_bytes(self, field_type: FieldType, value: Any) -> Optional[bytes]: if field_type == FieldType.STRING: return value.encode("utf-8") elif field_type == FieldType.BYTES: + if isinstance(value, io.BytesIO): + return value.read() return value return None @@ -420,3 +427,43 @@ def _register_kms_client(self, kms_driver: KmsDriver, config: dict, kek_url: str kms_client = kms_driver.new_kms_client(config, kek_url) register_kms_client(kms_client) return kms_client + + +class FieldEncryptionExecutor(FieldRuleExecutor): + + def __init__(self, clock: Clock = Clock()): + self.executor = EncryptionExecutor(clock) + + def configure(self, client_conf: dict, rule_conf: dict): + self.executor.configure(client_conf, rule_conf) + + def type(self) -> str: + return "ENCRYPT" + + def new_transform(self, ctx: RuleContext) -> FieldTransform: + executor_transform = self.executor.new_transform(ctx) + transform = FieldEncryptionExecutorTransform(executor_transform) + return transform.transform + + def close(self): + if self.client is not None: + self.client.__exit__() + + @classmethod + def register(cls): + RuleRegistry.register_rule_executor(FieldEncryptionExecutor()) + + @classmethod + def register_with_clock(cls, clock: Clock) -> 'FieldEncryptionExecutor': + executor = FieldEncryptionExecutor(clock) + RuleRegistry.register_rule_executor(executor) + return executor + + +class FieldEncryptionExecutorTransform(object): + + def __init__(self, executor_transform: 'EncryptionExecutorTransform'): + self.executor_transform = executor_transform + + def transform(self, ctx: RuleContext, field_ctx: FieldContext, field_value: Any) -> Any: + return self.executor_transform.transform(ctx, field_ctx.field_type, field_value) diff --git a/tests/schema_registry/_async/test_avro_serdes.py b/tests/schema_registry/_async/test_avro_serdes.py index bc06fcd64..bb95f4098 100644 --- a/tests/schema_registry/_async/test_avro_serdes.py +++ b/tests/schema_registry/_async/test_avro_serdes.py @@ -38,7 +38,7 @@ from confluent_kafka.schema_registry.rules.encryption.dek_registry.dek_registry_client import \ DekRegistryClient, DekAlgorithm from confluent_kafka.schema_registry.rules.encryption.encrypt_executor import \ - FieldEncryptionExecutor, Clock + FieldEncryptionExecutor, Clock, EncryptionExecutor from confluent_kafka.schema_registry.rules.encryption.gcpkms.gcp_driver import \ GcpKmsDriver from confluent_kafka.schema_registry.rules.encryption.hcvault.hcvault_driver import \ @@ -1125,7 +1125,7 @@ async def test_avro_encryption(): 'bytesField': b'foobar', } ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1134,6 +1134,68 @@ async def test_avro_encryption(): obj['stringField'] = 'hi' obj['bytesField'] = b'foobar' + deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) + executor.executor.client = dek_client + obj2 = await deser(obj_bytes, ser_ctx) + assert obj == obj2 + + +async def test_avro_payload_encryption(): + executor = EncryptionExecutor.register_with_clock(FakeClock()) + + conf = {'url': _BASE_URL} + client = AsyncSchemaRegistryClient.new_client(conf) + ser_conf = {'auto.register.schemas': False, 'use.latest.version': True} + rule_conf = {'secret': 'mysecret'} + schema = { + 'type': 'record', + 'name': 'test', + 'fields': [ + {'name': 'intField', 'type': 'int'}, + {'name': 'doubleField', 'type': 'double'}, + {'name': 'stringField', 'type': 'string', 'confluent:tags': ['PII']}, + {'name': 'booleanField', 'type': 'boolean'}, + {'name': 'bytesField', 'type': 'bytes', 'confluent:tags': ['PII']}, + ] + } + + rule = Rule( + "test-encrypt", + "", + RuleKind.TRANSFORM, + RuleMode.WRITEREAD, + "ENCRYPT_PAYLOAD", + None, + RuleParams({ + "encrypt.kek.name": "kek1", + "encrypt.kms.type": "local-kms", + "encrypt.kms.key.id": "mykey" + }), + None, + None, + "ERROR,NONE", + False + ) + await client.register_schema(_SUBJECT, Schema( + json.dumps(schema), + "AVRO", + [], + None, + RuleSet(None, None, [rule]) + )) + + obj = { + 'intField': 123, + 'doubleField': 45.67, + 'stringField': 'hi', + 'booleanField': True, + 'bytesField': b'foobar', + } + ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) + dek_client = executor.client + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + obj_bytes = await ser(obj, ser_ctx) + deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) @@ -1193,7 +1255,7 @@ async def test_avro_encryption_deterministic(): 'bytesField': b'foobar', } ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1203,7 +1265,7 @@ async def test_avro_encryption_deterministic(): obj['bytesField'] = b'foobar' deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1273,7 +1335,7 @@ async def test_avro_encryption_cel(): 'bytesField': b'foobar', } ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1283,7 +1345,7 @@ async def test_avro_encryption_cel(): obj['bytesField'] = b'foobar' deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1341,7 +1403,7 @@ async def test_avro_encryption_dek_rotation(): 'bytesField': b'foobar', } ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1350,17 +1412,17 @@ async def test_avro_encryption_dek_rotation(): obj['stringField'] = 'hi' deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 - dek_client = executor.client + dek_client = executor.executor.client dek = dek_client.get_dek("kek1-rot", _SUBJECT, version=-1) assert dek.version == 1 # advance 2 days now = datetime.now() + timedelta(days=2) - executor.clock.fixed_now = int(round(now.timestamp() * 1000)) + executor.executor.clock.fixed_now = int(round(now.timestamp() * 1000)) obj_bytes = await ser(obj, ser_ctx) @@ -1376,7 +1438,7 @@ async def test_avro_encryption_dek_rotation(): # advance 2 days now = datetime.now() + timedelta(days=2) - executor.clock.fixed_now = int(round(now.timestamp() * 1000)) + executor.executor.clock.fixed_now = int(round(now.timestamp() * 1000)) obj_bytes = await ser(obj, ser_ctx) @@ -1437,7 +1499,7 @@ async def test_avro_encryption_f1_preserialized(): ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client dek_client.register_kek("kek1-f1", "local-kms", "mykey") encrypted_dek = "07V2ndh02DA73p+dTybwZFm7DKQSZN1tEwQh+FoX1DZLk4Yj2LLu4omYjp/84tAg3BYlkfGSz+zZacJHIE4=" @@ -1500,7 +1562,7 @@ async def test_avro_encryption_deterministic_f1_preserialized(): ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client dek_client.register_kek("kek1-det-f1", "local-kms", "mykey") encrypted_dek = ("YSx3DTlAHrmpoDChquJMifmPntBzxgRVdMzgYL82rgWBKn7aUSnG+WIu9oz" @@ -1562,7 +1624,7 @@ async def test_avro_encryption_dek_rotation_f1_preserialized(): ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client dek_client.register_kek("kek1-rot-f1", "local-kms", "mykey") encrypted_dek = "W/v6hOQYq1idVAcs1pPWz9UUONMVZW4IrglTnG88TsWjeCjxmtRQ4VaNe/I5dCfm2zyY9Cu0nqdvqImtUk4=" @@ -1642,7 +1704,7 @@ async def test_avro_encryption_references(): )) ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1652,7 +1714,7 @@ async def test_avro_encryption_references(): obj['refField']['bytesField'] = b'foobar' deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1709,7 +1771,7 @@ async def test_avro_encryption_with_union(): 'bytesField': b'foobar', } ser = await AsyncAvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1719,7 +1781,7 @@ async def test_avro_encryption_with_union(): obj['bytesField'] = b'foobar' deser = await AsyncAvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 diff --git a/tests/schema_registry/_async/test_json_serdes.py b/tests/schema_registry/_async/test_json_serdes.py index 69e984bb8..31a4aed55 100644 --- a/tests/schema_registry/_async/test_json_serdes.py +++ b/tests/schema_registry/_async/test_json_serdes.py @@ -32,7 +32,7 @@ from confluent_kafka.schema_registry.rules.encryption.azurekms.azure_driver import \ AzureKmsDriver from confluent_kafka.schema_registry.rules.encryption.encrypt_executor import \ - FieldEncryptionExecutor + FieldEncryptionExecutor, EncryptionExecutor from confluent_kafka.schema_registry.rules.encryption.gcpkms.gcp_driver import \ GcpKmsDriver from confluent_kafka.schema_registry.rules.encryption.hcvault.hcvault_driver import \ @@ -980,7 +980,7 @@ async def test_json_encryption(): 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), } ser = await AsyncJSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -989,6 +989,74 @@ async def test_json_encryption(): obj['stringField'] = 'hi' obj['bytesField'] = base64.b64encode(b'foobar').decode('utf-8') + deser = await AsyncJSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) + executor.executor.client = dek_client + obj2 = await deser(obj_bytes, ser_ctx) + assert obj == obj2 + + +async def test_json_payloadencryption(): + executor = EncryptionExecutor.register_with_clock(FakeClock()) + + conf = {'url': _BASE_URL} + client = AsyncSchemaRegistryClient.new_client(conf) + ser_conf = {'auto.register.schemas': False, 'use.latest.version': True} + rule_conf = {'secret': 'mysecret'} + schema = { + "type": "object", + "properties": { + "intField": {"type": "integer"}, + "doubleField": {"type": "number"}, + "stringField": { + "type": "string", + "confluent:tags": ["PII"] + }, + "booleanField": {"type": "boolean"}, + "bytesField": { + "type": "string", + "contentEncoding": "base64", + "confluent:tags": ["PII"] + } + } + } + + rule = Rule( + "test-encrypt", + "", + RuleKind.TRANSFORM, + RuleMode.WRITEREAD, + "ENCRYPT_PAYLOAD", + None, + RuleParams({ + "encrypt.kek.name": "kek1", + "encrypt.kms.type": "local-kms", + "encrypt.kms.key.id": "mykey" + }), + None, + None, + "ERROR,NONE", + False + ) + await client.register_schema(_SUBJECT, Schema( + json.dumps(schema), + "JSON", + [], + None, + RuleSet(None, None, [rule]) + )) + + obj = { + 'intField': 123, + 'doubleField': 45.67, + 'stringField': 'hi', + 'booleanField': True, + 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), + } + ser = await AsyncJSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) + dek_client = executor.client + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + obj_bytes = await ser(obj, ser_ctx) + deser = await AsyncJSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) @@ -1060,7 +1128,7 @@ async def test_json_encryption_with_union(): 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), } ser = await AsyncJSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1070,7 +1138,7 @@ async def test_json_encryption_with_union(): obj['bytesField'] = base64.b64encode(b'foobar').decode('utf-8') deser = await AsyncJSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1151,7 +1219,7 @@ async def test_json_encryption_with_references(): 'otherField': nested } ser = await AsyncJSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -1161,7 +1229,7 @@ async def test_json_encryption_with_references(): obj['otherField']['bytesField'] = base64.b64encode(b'foobar').decode('utf-8') deser = await AsyncJSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = await deser(obj_bytes, ser_ctx) assert obj == obj2 diff --git a/tests/schema_registry/_async/test_proto_serdes.py b/tests/schema_registry/_async/test_proto_serdes.py index 053db03ff..5c742ac38 100644 --- a/tests/schema_registry/_async/test_proto_serdes.py +++ b/tests/schema_registry/_async/test_proto_serdes.py @@ -34,7 +34,7 @@ from confluent_kafka.schema_registry.rules.encryption.azurekms.azure_driver import \ AzureKmsDriver from confluent_kafka.schema_registry.rules.encryption.encrypt_executor import \ - FieldEncryptionExecutor, Clock + FieldEncryptionExecutor, Clock, EncryptionExecutor from confluent_kafka.schema_registry.rules.encryption.gcpkms.gcp_driver import \ GcpKmsDriver from confluent_kafka.schema_registry.rules.encryption.hcvault.hcvault_driver import \ @@ -568,7 +568,7 @@ async def test_proto_encryption(): oneof_string='oneof' ) ser = await AsyncProtobufSerializer(example_pb2.Author, client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = await ser(obj, ser_ctx) @@ -582,6 +582,62 @@ async def test_proto_encryption(): oneof_string='oneof' ) + deser_conf = { + 'use.deprecated.format': False + } + deser = await AsyncProtobufDeserializer(example_pb2.Author, deser_conf, client, rule_conf=rule_conf) + executor.executor.client = dek_client + obj2 = await deser(obj_bytes, ser_ctx) + assert obj == obj2 + + +async def test_proto_payload_encryption(): + executor = EncryptionExecutor.register_with_clock(FakeClock()) + + conf = {'url': _BASE_URL} + client = AsyncSchemaRegistryClient.new_client(conf) + ser_conf = { + 'auto.register.schemas': False, + 'use.latest.version': True, + 'use.deprecated.format': False + } + rule_conf = {'secret': 'mysecret'} + rule = Rule( + "test-encrypt", + "", + RuleKind.TRANSFORM, + RuleMode.WRITEREAD, + "ENCRYPT_PAYLOAD", + None, + RuleParams({ + "encrypt.kek.name": "kek1", + "encrypt.kms.type": "local-kms", + "encrypt.kms.key.id": "mykey" + }), + None, + None, + "ERROR,NONE", + False + ) + await client.register_schema(_SUBJECT, Schema( + _schema_to_str(example_pb2.Author.DESCRIPTOR.file), + "PROTOBUF", + [], + None, + RuleSet(None, None, [rule]) + )) + obj = example_pb2.Author( + name='Kafka', + id=123, + picture=b'foobar', + works=['The Castle', 'TheTrial'], + oneof_string='oneof' + ) + ser = await AsyncProtobufSerializer(example_pb2.Author, client, conf=ser_conf, rule_conf=rule_conf) + dek_client = executor.client + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + obj_bytes = await ser(obj, ser_ctx) + deser_conf = { 'use.deprecated.format': False } diff --git a/tests/schema_registry/_sync/test_avro_serdes.py b/tests/schema_registry/_sync/test_avro_serdes.py index dadf530db..735d0f29a 100644 --- a/tests/schema_registry/_sync/test_avro_serdes.py +++ b/tests/schema_registry/_sync/test_avro_serdes.py @@ -38,7 +38,7 @@ from confluent_kafka.schema_registry.rules.encryption.dek_registry.dek_registry_client import \ DekRegistryClient, DekAlgorithm from confluent_kafka.schema_registry.rules.encryption.encrypt_executor import \ - FieldEncryptionExecutor, Clock + FieldEncryptionExecutor, Clock, EncryptionExecutor from confluent_kafka.schema_registry.rules.encryption.gcpkms.gcp_driver import \ GcpKmsDriver from confluent_kafka.schema_registry.rules.encryption.hcvault.hcvault_driver import \ @@ -1125,7 +1125,7 @@ def test_avro_encryption(): 'bytesField': b'foobar', } ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1134,6 +1134,68 @@ def test_avro_encryption(): obj['stringField'] = 'hi' obj['bytesField'] = b'foobar' + deser = AvroDeserializer(client, rule_conf=rule_conf) + executor.executor.client = dek_client + obj2 = deser(obj_bytes, ser_ctx) + assert obj == obj2 + + +def test_avro_payload_encryption(): + executor = EncryptionExecutor.register_with_clock(FakeClock()) + + conf = {'url': _BASE_URL} + client = SchemaRegistryClient.new_client(conf) + ser_conf = {'auto.register.schemas': False, 'use.latest.version': True} + rule_conf = {'secret': 'mysecret'} + schema = { + 'type': 'record', + 'name': 'test', + 'fields': [ + {'name': 'intField', 'type': 'int'}, + {'name': 'doubleField', 'type': 'double'}, + {'name': 'stringField', 'type': 'string', 'confluent:tags': ['PII']}, + {'name': 'booleanField', 'type': 'boolean'}, + {'name': 'bytesField', 'type': 'bytes', 'confluent:tags': ['PII']}, + ] + } + + rule = Rule( + "test-encrypt", + "", + RuleKind.TRANSFORM, + RuleMode.WRITEREAD, + "ENCRYPT_PAYLOAD", + None, + RuleParams({ + "encrypt.kek.name": "kek1", + "encrypt.kms.type": "local-kms", + "encrypt.kms.key.id": "mykey" + }), + None, + None, + "ERROR,NONE", + False + ) + client.register_schema(_SUBJECT, Schema( + json.dumps(schema), + "AVRO", + [], + None, + RuleSet(None, None, [rule]) + )) + + obj = { + 'intField': 123, + 'doubleField': 45.67, + 'stringField': 'hi', + 'booleanField': True, + 'bytesField': b'foobar', + } + ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) + dek_client = executor.client + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + obj_bytes = ser(obj, ser_ctx) + deser = AvroDeserializer(client, rule_conf=rule_conf) executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) @@ -1193,7 +1255,7 @@ def test_avro_encryption_deterministic(): 'bytesField': b'foobar', } ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1203,7 +1265,7 @@ def test_avro_encryption_deterministic(): obj['bytesField'] = b'foobar' deser = AvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1273,7 +1335,7 @@ def test_avro_encryption_cel(): 'bytesField': b'foobar', } ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1283,7 +1345,7 @@ def test_avro_encryption_cel(): obj['bytesField'] = b'foobar' deser = AvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1341,7 +1403,7 @@ def test_avro_encryption_dek_rotation(): 'bytesField': b'foobar', } ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1350,17 +1412,17 @@ def test_avro_encryption_dek_rotation(): obj['stringField'] = 'hi' deser = AvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 - dek_client = executor.client + dek_client = executor.executor.client dek = dek_client.get_dek("kek1-rot", _SUBJECT, version=-1) assert dek.version == 1 # advance 2 days now = datetime.now() + timedelta(days=2) - executor.clock.fixed_now = int(round(now.timestamp() * 1000)) + executor.executor.clock.fixed_now = int(round(now.timestamp() * 1000)) obj_bytes = ser(obj, ser_ctx) @@ -1376,7 +1438,7 @@ def test_avro_encryption_dek_rotation(): # advance 2 days now = datetime.now() + timedelta(days=2) - executor.clock.fixed_now = int(round(now.timestamp() * 1000)) + executor.executor.clock.fixed_now = int(round(now.timestamp() * 1000)) obj_bytes = ser(obj, ser_ctx) @@ -1437,7 +1499,7 @@ def test_avro_encryption_f1_preserialized(): ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) deser = AvroDeserializer(client, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client dek_client.register_kek("kek1-f1", "local-kms", "mykey") encrypted_dek = "07V2ndh02DA73p+dTybwZFm7DKQSZN1tEwQh+FoX1DZLk4Yj2LLu4omYjp/84tAg3BYlkfGSz+zZacJHIE4=" @@ -1500,7 +1562,7 @@ def test_avro_encryption_deterministic_f1_preserialized(): ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) deser = AvroDeserializer(client, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client dek_client.register_kek("kek1-det-f1", "local-kms", "mykey") encrypted_dek = ("YSx3DTlAHrmpoDChquJMifmPntBzxgRVdMzgYL82rgWBKn7aUSnG+WIu9oz" @@ -1562,7 +1624,7 @@ def test_avro_encryption_dek_rotation_f1_preserialized(): ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) deser = AvroDeserializer(client, rule_conf=rule_conf) - dek_client: DekRegistryClient = executor.client + dek_client: DekRegistryClient = executor.executor.client dek_client.register_kek("kek1-rot-f1", "local-kms", "mykey") encrypted_dek = "W/v6hOQYq1idVAcs1pPWz9UUONMVZW4IrglTnG88TsWjeCjxmtRQ4VaNe/I5dCfm2zyY9Cu0nqdvqImtUk4=" @@ -1642,7 +1704,7 @@ def test_avro_encryption_references(): )) ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1652,7 +1714,7 @@ def test_avro_encryption_references(): obj['refField']['bytesField'] = b'foobar' deser = AvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1709,7 +1771,7 @@ def test_avro_encryption_with_union(): 'bytesField': b'foobar', } ser = AvroSerializer(client, schema_str=None, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1719,7 +1781,7 @@ def test_avro_encryption_with_union(): obj['bytesField'] = b'foobar' deser = AvroDeserializer(client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 diff --git a/tests/schema_registry/_sync/test_json_serdes.py b/tests/schema_registry/_sync/test_json_serdes.py index 56b3714a2..3e1c7b4b3 100644 --- a/tests/schema_registry/_sync/test_json_serdes.py +++ b/tests/schema_registry/_sync/test_json_serdes.py @@ -32,7 +32,7 @@ from confluent_kafka.schema_registry.rules.encryption.azurekms.azure_driver import \ AzureKmsDriver from confluent_kafka.schema_registry.rules.encryption.encrypt_executor import \ - FieldEncryptionExecutor + FieldEncryptionExecutor, EncryptionExecutor from confluent_kafka.schema_registry.rules.encryption.gcpkms.gcp_driver import \ GcpKmsDriver from confluent_kafka.schema_registry.rules.encryption.hcvault.hcvault_driver import \ @@ -980,7 +980,7 @@ def test_json_encryption(): 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), } ser = JSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -989,6 +989,74 @@ def test_json_encryption(): obj['stringField'] = 'hi' obj['bytesField'] = base64.b64encode(b'foobar').decode('utf-8') + deser = JSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) + executor.executor.client = dek_client + obj2 = deser(obj_bytes, ser_ctx) + assert obj == obj2 + + +def test_json_payloadencryption(): + executor = EncryptionExecutor.register_with_clock(FakeClock()) + + conf = {'url': _BASE_URL} + client = SchemaRegistryClient.new_client(conf) + ser_conf = {'auto.register.schemas': False, 'use.latest.version': True} + rule_conf = {'secret': 'mysecret'} + schema = { + "type": "object", + "properties": { + "intField": {"type": "integer"}, + "doubleField": {"type": "number"}, + "stringField": { + "type": "string", + "confluent:tags": ["PII"] + }, + "booleanField": {"type": "boolean"}, + "bytesField": { + "type": "string", + "contentEncoding": "base64", + "confluent:tags": ["PII"] + } + } + } + + rule = Rule( + "test-encrypt", + "", + RuleKind.TRANSFORM, + RuleMode.WRITEREAD, + "ENCRYPT_PAYLOAD", + None, + RuleParams({ + "encrypt.kek.name": "kek1", + "encrypt.kms.type": "local-kms", + "encrypt.kms.key.id": "mykey" + }), + None, + None, + "ERROR,NONE", + False + ) + client.register_schema(_SUBJECT, Schema( + json.dumps(schema), + "JSON", + [], + None, + RuleSet(None, None, [rule]) + )) + + obj = { + 'intField': 123, + 'doubleField': 45.67, + 'stringField': 'hi', + 'booleanField': True, + 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), + } + ser = JSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) + dek_client = executor.client + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + obj_bytes = ser(obj, ser_ctx) + deser = JSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) @@ -1060,7 +1128,7 @@ def test_json_encryption_with_union(): 'bytesField': base64.b64encode(b'foobar').decode('utf-8'), } ser = JSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1070,7 +1138,7 @@ def test_json_encryption_with_union(): obj['bytesField'] = base64.b64encode(b'foobar').decode('utf-8') deser = JSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 @@ -1151,7 +1219,7 @@ def test_json_encryption_with_references(): 'otherField': nested } ser = JSONSerializer(json.dumps(schema), client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -1161,7 +1229,7 @@ def test_json_encryption_with_references(): obj['otherField']['bytesField'] = base64.b64encode(b'foobar').decode('utf-8') deser = JSONDeserializer(None, schema_registry_client=client, rule_conf=rule_conf) - executor.client = dek_client + executor.executor.client = dek_client obj2 = deser(obj_bytes, ser_ctx) assert obj == obj2 diff --git a/tests/schema_registry/_sync/test_proto_serdes.py b/tests/schema_registry/_sync/test_proto_serdes.py index 5448240c2..1ce23c807 100644 --- a/tests/schema_registry/_sync/test_proto_serdes.py +++ b/tests/schema_registry/_sync/test_proto_serdes.py @@ -34,7 +34,7 @@ from confluent_kafka.schema_registry.rules.encryption.azurekms.azure_driver import \ AzureKmsDriver from confluent_kafka.schema_registry.rules.encryption.encrypt_executor import \ - FieldEncryptionExecutor, Clock + FieldEncryptionExecutor, Clock, EncryptionExecutor from confluent_kafka.schema_registry.rules.encryption.gcpkms.gcp_driver import \ GcpKmsDriver from confluent_kafka.schema_registry.rules.encryption.hcvault.hcvault_driver import \ @@ -568,7 +568,7 @@ def test_proto_encryption(): oneof_string='oneof' ) ser = ProtobufSerializer(example_pb2.Author, client, conf=ser_conf, rule_conf=rule_conf) - dek_client = executor.client + dek_client = executor.executor.client ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) obj_bytes = ser(obj, ser_ctx) @@ -582,6 +582,62 @@ def test_proto_encryption(): oneof_string='oneof' ) + deser_conf = { + 'use.deprecated.format': False + } + deser = ProtobufDeserializer(example_pb2.Author, deser_conf, client, rule_conf=rule_conf) + executor.executor.client = dek_client + obj2 = deser(obj_bytes, ser_ctx) + assert obj == obj2 + + +def test_proto_payload_encryption(): + executor = EncryptionExecutor.register_with_clock(FakeClock()) + + conf = {'url': _BASE_URL} + client = SchemaRegistryClient.new_client(conf) + ser_conf = { + 'auto.register.schemas': False, + 'use.latest.version': True, + 'use.deprecated.format': False + } + rule_conf = {'secret': 'mysecret'} + rule = Rule( + "test-encrypt", + "", + RuleKind.TRANSFORM, + RuleMode.WRITEREAD, + "ENCRYPT_PAYLOAD", + None, + RuleParams({ + "encrypt.kek.name": "kek1", + "encrypt.kms.type": "local-kms", + "encrypt.kms.key.id": "mykey" + }), + None, + None, + "ERROR,NONE", + False + ) + client.register_schema(_SUBJECT, Schema( + _schema_to_str(example_pb2.Author.DESCRIPTOR.file), + "PROTOBUF", + [], + None, + RuleSet(None, None, [rule]) + )) + obj = example_pb2.Author( + name='Kafka', + id=123, + picture=b'foobar', + works=['The Castle', 'TheTrial'], + oneof_string='oneof' + ) + ser = ProtobufSerializer(example_pb2.Author, client, conf=ser_conf, rule_conf=rule_conf) + dek_client = executor.client + ser_ctx = SerializationContext(_TOPIC, MessageField.VALUE) + obj_bytes = ser(obj, ser_ctx) + deser_conf = { 'use.deprecated.format': False } From 7039489e7ed76c55be240baa79a92a8d3b0bfe96 Mon Sep 17 00:00:00 2001 From: Naxin Fang Date: Tue, 5 Aug 2025 14:58:35 -0400 Subject: [PATCH 11/12] Upgrade cel-python to 0.4.0 and update usage (#2015) * celpy fix * fix * update --- requirements/requirements-examples.txt | 2 +- requirements/requirements-rules.txt | 4 ++-- .../schema_registry/rules/cel/cel_field_presence.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/requirements-examples.txt b/requirements/requirements-examples.txt index 87f4a7a32..c26c25de3 100644 --- a/requirements/requirements-examples.txt +++ b/requirements/requirements-examples.txt @@ -23,7 +23,7 @@ protobuf azure-identity azure-keyvault-keys boto3 -cel-python>=0.1.5 +cel-python>=0.4.0 google-auth google-api-core google-cloud-kms diff --git a/requirements/requirements-rules.txt b/requirements/requirements-rules.txt index 98dcaba4a..4dcf89d83 100644 --- a/requirements/requirements-rules.txt +++ b/requirements/requirements-rules.txt @@ -1,7 +1,7 @@ azure-identity azure-keyvault-keys boto3>=1.35 -cel-python>=0.1.5 +cel-python>=0.4.0 google-auth google-api-core google-cloud-kms @@ -10,4 +10,4 @@ hvac jsonata-python # Dependency of cel-python. Use version 6 due to https://github.com/yaml/pyyaml/issues/601 pyyaml>=6.0.0 -tink \ No newline at end of file +tink diff --git a/src/confluent_kafka/schema_registry/rules/cel/cel_field_presence.py b/src/confluent_kafka/schema_registry/rules/cel/cel_field_presence.py index e29685ea9..f78e4887e 100644 --- a/src/confluent_kafka/schema_registry/rules/cel/cel_field_presence.py +++ b/src/confluent_kafka/schema_registry/rules/cel/cel_field_presence.py @@ -35,12 +35,12 @@ def in_has() -> bool: class InterpretedRunner(celpy.InterpretedRunner): def evaluate(self, context): class Evaluator(celpy.Evaluator): - def macro_has_eval(self, exprlist): + def macro_has_eval(self, exprlist) -> celpy.celtypes.BoolType: _has_state.in_has = True result = super().macro_has_eval(exprlist) _has_state.in_has = False return result - e = Evaluator(ast=self.ast, activation=self.new_activation(context), functions=self.functions) - value = e.evaluate() + e = Evaluator(ast=self.ast, activation=self.new_activation()) + value = e.evaluate(context) return value From e9ba52e17595ba8094943fe9eaae70bc33c54749 Mon Sep 17 00:00:00 2001 From: Naxin Fang Date: Thu, 7 Aug 2025 11:23:06 -0400 Subject: [PATCH 12/12] Fix URLs in SchemaRegistryClient docs (#2014) * fix * typo --- .../schema_registry/_async/schema_registry_client.py | 10 +++++----- .../schema_registry/_sync/schema_registry_client.py | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/confluent_kafka/schema_registry/_async/schema_registry_client.py b/src/confluent_kafka/schema_registry/_async/schema_registry_client.py index f4ca12c32..72f26a106 100644 --- a/src/confluent_kafka/schema_registry/_async/schema_registry_client.py +++ b/src/confluent_kafka/schema_registry/_async/schema_registry_client.py @@ -753,7 +753,7 @@ async def lookup_schema( SchemaRegistryError: If schema or subject can't be found See Also: - `POST Subject API Reference `_ + `POST Subject API Reference `_ """ # noqa: E501 registered_schema = self._cache.get_registered_by_subject_schema(subject_name, schema) @@ -785,7 +785,7 @@ async def lookup_schema( async def get_subjects(self) -> List[str]: """ - List all subjects registered with the Schema Registry + Lists all subjects registered with the Schema Registry Returns: list(str): Registered subject names @@ -794,7 +794,7 @@ async def get_subjects(self) -> List[str]: SchemaRegistryError: if subjects can't be found See Also: - `GET subjects API Reference `_ + `GET subjects API Reference `_ """ # noqa: E501 return await self._rest_client.get('subjects') @@ -908,7 +908,7 @@ async def get_latest_with_metadata( return registered_schema async def get_version( - self, subject_name: str, version: int, + self, subject_name: str, version: Union[int, str] = "latest", deleted: bool = False, fmt: Optional[str] = None ) -> 'RegisteredSchema': """ @@ -927,7 +927,7 @@ async def get_version( SchemaRegistryError: if the version can't be found or is invalid. See Also: - `GET Subject Version API Reference `_ + `GET Subject Versions API Reference `_ """ # noqa: E501 registered_schema = self._cache.get_registered_by_subject_version(subject_name, version) diff --git a/src/confluent_kafka/schema_registry/_sync/schema_registry_client.py b/src/confluent_kafka/schema_registry/_sync/schema_registry_client.py index 2394172c4..3be266538 100644 --- a/src/confluent_kafka/schema_registry/_sync/schema_registry_client.py +++ b/src/confluent_kafka/schema_registry/_sync/schema_registry_client.py @@ -753,7 +753,7 @@ def lookup_schema( SchemaRegistryError: If schema or subject can't be found See Also: - `POST Subject API Reference `_ + `POST Subject API Reference `_ """ # noqa: E501 registered_schema = self._cache.get_registered_by_subject_schema(subject_name, schema) @@ -785,7 +785,7 @@ def lookup_schema( def get_subjects(self) -> List[str]: """ - List all subjects registered with the Schema Registry + Lists all subjects registered with the Schema Registry Returns: list(str): Registered subject names @@ -794,7 +794,7 @@ def get_subjects(self) -> List[str]: SchemaRegistryError: if subjects can't be found See Also: - `GET subjects API Reference `_ + `GET subjects API Reference `_ """ # noqa: E501 return self._rest_client.get('subjects') @@ -908,7 +908,7 @@ def get_latest_with_metadata( return registered_schema def get_version( - self, subject_name: str, version: int, + self, subject_name: str, version: Union[int, str] = "latest", deleted: bool = False, fmt: Optional[str] = None ) -> 'RegisteredSchema': """ @@ -927,7 +927,7 @@ def get_version( SchemaRegistryError: if the version can't be found or is invalid. See Also: - `GET Subject Version API Reference `_ + `GET Subject Versions API Reference `_ """ # noqa: E501 registered_schema = self._cache.get_registered_by_subject_version(subject_name, version) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy