From 0f45e4bca9491ed8d83241b09ef2f1a9eba31cb9 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 15:00:48 +0200 Subject: [PATCH 01/32] Integrated Native Python Proto Buf Pythons & Removed Maven/Java dependency, Upgraded code for latest up-spec 7af90bd9aee517e79b7a41427d37de28af0f6de0 --- .coveragerc | 4 +- .gitmodules | 3 + README.adoc | 15 +- clean_project.py | 26 + generate_proto.py | 67 ++ pyproject.toml | 46 +- run_tests.py | 18 + scripts/pull_and_compile_protos.py | 105 --- .../test_inmemoryusubcriptionclient.py | 689 +++--------------- tests/test_communication/test_rpcmapper.py | 64 ++ .../test_validation}/__init__.py | 0 up-spec | 1 + .../v3/inmemoryusubcriptionclient.py | 358 ++------- uprotocol/communication/rpcmapper.py | 36 +- 14 files changed, 378 insertions(+), 1054 deletions(-) create mode 100644 .gitmodules create mode 100644 generate_proto.py create mode 100644 run_tests.py delete mode 100644 scripts/pull_and_compile_protos.py rename {scripts => tests/test_validation}/__init__.py (100%) create mode 160000 up-spec diff --git a/.coveragerc b/.coveragerc index d55c0ad..7e4f124 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] -omit = uprotocol/core/*,uprotocol/v1/*, uprotocol/uoptions_pb2.py, uprotocol/cloudevent/*_pb2.py, tests/*, */__init__.py +omit = uprotocol/core/*,uprotocol/v1/*, uprotocol/uoptions_pb2.py ,uprotocol/uoptions_pb2_grpc.py ,uprotocol/cloudevent/*_pb2.py, tests/*, */__init__.py [report] exclude_lines = pragma: no cover - .*pass.* \ No newline at end of file + .*pass.* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..4b9f47b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "up-spec"] + path = up-spec + url = https://github.com/eclipse-uprotocol/up-spec.git diff --git a/README.adoc b/README.adoc index 45a492c..d493ce5 100644 --- a/README.adoc +++ b/README.adoc @@ -18,14 +18,7 @@ image:https://raw.githubusercontent.com/eclipse-uprotocol/up-spec/main/up_librar === Prerequisites Before proceeding with the setup of this project, ensure that the following prerequisites are met: -* Maven is installed and configured in your environment. You can verify this by running the following command in your terminal: -[,bash] ----- -mvn -version ----- -If Maven is properly installed, you should see information about the Maven version and configuration. + - -NOTE: Ensure you are using Java 17 with your Maven installation before continuing with the next steps. Other versions of Java may not be supported. +* python & pip is installed === Importing the Library @@ -38,11 +31,11 @@ To set up SDK, follow the steps below: git clone https://github.com/eclipse-uprotocol/up-python.git ---- -. Execute the `pull_and_compile_protos.py` script using the following commands: +Pull the latest up-spec as submodule and generate the protobuf: + [source] ---- -cd scripts +git submodule update --init --recursive python pull_and_compile_protos.py ---- This script automates the following tasks: @@ -54,7 +47,7 @@ This script automates the following tasks: + [source] ---- -python -m pip install ../ +python -m pip install . ---- *This will install up-python, making its classes and modules available for import in your python code.* diff --git a/clean_project.py b/clean_project.py index 0f0c265..1c0d134 100644 --- a/clean_project.py +++ b/clean_project.py @@ -23,21 +23,47 @@ import os import shutil +TRACK_FILE = "generated_proto_files.txt" def clean_project(): # Remove build/ directory if os.path.exists('build'): shutil.rmtree('build') + print("Removed build/") # Remove dist/ directory if os.path.exists('dist'): shutil.rmtree('dist') + print("Removed dist/") # Remove *.egg-info/ directories egg_info_directories = [d for d in os.listdir() if d.endswith('.egg-info')] for egg_info_directory in egg_info_directories: shutil.rmtree(egg_info_directory) + print(f"Removed {egg_info_directory}/") + # Remove generated proto files + if os.path.exists(TRACK_FILE): + with open(TRACK_FILE, "r") as f: + files = [line.strip() for line in f if line.strip()] + for file in files: + if os.path.exists(file): + os.remove(file) + print(f"Deleted {file}") + else: + print(f"{file} not found, skipping.") + os.remove(TRACK_FILE) + print(f"Removed {TRACK_FILE}") + else: + print(f"No {TRACK_FILE} found, skipping proto cleanup.") + + # Remove __pycache__ folders recursively + for root, dirs, _ in os.walk('.'): + for d in dirs: + if d == "__pycache__": + cache_path = os.path.join(root, d) + shutil.rmtree(cache_path) + print(f"Removed {cache_path}") if __name__ == "__main__": clean_project() diff --git a/generate_proto.py b/generate_proto.py new file mode 100644 index 0000000..8d7412c --- /dev/null +++ b/generate_proto.py @@ -0,0 +1,67 @@ +""" +SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation + +See the NOTICE file(s) distributed with this work for additional +information regarding copyright ownership. + +This program and the accompanying materials are made available under the +terms of the Apache License Version 2.0 which is available at + + http://www.apache.org/licenses/LICENSE-2.0 + +SPDX-License-Identifier: Apache-2.0 +""" + +from grpc_tools import protoc +import os + +PROTO_DIR = "up-spec/up-core-api" +OUTPUT_DIR = "." +TRACK_FILE = "generated_proto_files.txt" + +def generate_all_protos(): + if not os.path.exists(OUTPUT_DIR): + os.makedirs(OUTPUT_DIR) + + generated_files = [] + + for root, _, files in os.walk(PROTO_DIR): + for file in files: + if file.endswith(".proto"): + proto_file = os.path.join(root, file) + print(f"Compiling {proto_file}...") + result = protoc.main([ + '', + f'-I{PROTO_DIR}', + f'--python_out={OUTPUT_DIR}', + f'--grpc_python_out={OUTPUT_DIR}', + proto_file + ]) + if result != 0: + print(f"Failed to compile {proto_file}") + else: + print(f"Compiled {proto_file} successfully.") + + base_name = os.path.splitext(file)[0] + rel_dir = os.path.relpath(root, PROTO_DIR) + output_dir = os.path.join(OUTPUT_DIR, rel_dir) + + generated_files.append(os.path.join(output_dir, f"{base_name}_pb2.py")) + generated_files.append(os.path.join(output_dir, f"{base_name}_pb2_grpc.py")) + + # Ensure __init__.py in all generated proto folders + for root, dirs, files in os.walk(OUTPUT_DIR): + init_file = os.path.join(root, '__init__.py') + if not os.path.exists(init_file): + open(init_file, 'a').close() + print(f"Created {init_file}") + generated_files.append(init_file) + + # Write generated files to track file + with open(TRACK_FILE, "w") as f: + for path in generated_files: + f.write(f"{path}\n") + print(f"Tracked {len(generated_files)} generated files in {TRACK_FILE}") + +if __name__ == "__main__": + generate_all_protos() diff --git a/pyproject.toml b/pyproject.toml index 48ce3f8..3bef3e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,29 +1,33 @@ -[tool.poetry] +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] name = "up-python" version = "0.2.0-dev" description = "Language specific uProtocol library for building and using UUri, UUID, UAttributes, UTransport, and more." -authors = ["Neelam Kushwah "] -license = "The Apache License, Version 2.0" -readme = "README.adoc" -repository = "https://github.com/eclipse-uprotocol/up-python" -packages = [{ include = "uprotocol" }, - { include = "tests" }, - { include = "scripts" } +authors = [{name = "Neelam Kushwah", email = "neelam.kushwah@gm.com"}] +license = "Apache-2.0" +readme = {file = "README.adoc", content-type = "text/asciidoc"} +dependencies = [ + "cloudevents", + "googleapis-common-protos>=1.56.4", + "protobuf>=4.24.2", + "grpcio>=1.60.0", + "grpcio-tools>=1.60.0", ] -[tool.poetry.dependencies] -python = "^3.8" -cloudevents = "*" -gitpython = ">=3.1.41" -googleapis-common-protos = ">=1.56.4" -protobuf = "4.24.2" -pytest = ">=6.2.5" -pytest-asyncio = ">=0.15.1" -coverage = ">=6.5.0" -pytest-timeout = ">=1.4.2" +[project.urls] +Repository = "https://github.com/eclipse-uprotocol/up-python" +[project.optional-dependencies] +dev = [ + "pytest>=6.2.5", + "pytest-asyncio>=0.15.1", + "coverage>=6.5.0", + "pytest-timeout>=1.4.2", +] -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" \ No newline at end of file +[tool.setuptools.packages.find] +include = ["uprotocol*", "tests*", "scripts*"] diff --git a/run_tests.py b/run_tests.py new file mode 100644 index 0000000..8165a12 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,18 @@ +import subprocess + +def run_tests_with_coverage(): + print("\nRunning tests with coverage...") + subprocess.run(["python", "-m", "coverage", "run", "--source", "uprotocol/", "-m", "pytest", "-v"]) + + print("\nGenerating terminal coverage report...") + subprocess.run(["python", "-m", "coverage", "report"]) + + print("\nGenerating HTML coverage report...") + subprocess.run(["python", "-m", "coverage", "html"]) + + print("\nHTML report generated in ./htmlcov/index.html") + print("Open it in your browser to view detailed coverage.") + +if __name__ == "__main__": + run_tests_with_coverage() + diff --git a/scripts/pull_and_compile_protos.py b/scripts/pull_and_compile_protos.py deleted file mode 100644 index 3a8fff4..0000000 --- a/scripts/pull_and_compile_protos.py +++ /dev/null @@ -1,105 +0,0 @@ -""" -SPDX-FileCopyrightText: 2023 Contributors to the Eclipse Foundation - -See the NOTICE file(s) distributed with this work for additional -information regarding copyright ownership. - -This program and the accompanying materials are made available under the -terms of the Apache License Version 2.0 which is available at - - http://www.apache.org/licenses/LICENSE-2.0 - -SPDX-License-Identifier: Apache-2.0 -""" - -import os -import re -import shutil -import subprocess - -import git -from git import Repo - -REPO_URL = "https://github.com/eclipse-uprotocol/up-spec.git" -PROTO_REPO_DIR = os.path.abspath("../target") -TAG_NAME = "v1.6.0-alpha.2" -PROTO_OUTPUT_DIR = os.path.abspath("../uprotocol/") - - -def clone_or_pull(repo_url, proto_repo_dir): - try: - repo = Repo.clone_from(repo_url, proto_repo_dir) - print(f"Repository cloned successfully from {repo_url} to {proto_repo_dir}") - # Checkout the specific tag - repo.git.checkout(TAG_NAME) - except git.exc.GitCommandError: - try: - git_pull_command = ["git", "pull", "origin", TAG_NAME] - subprocess.run(git_pull_command, cwd=proto_repo_dir, check=True) - print("Git pull successful after clone failure.") - except subprocess.CalledProcessError as pull_error: - print(f"Error during Git pull: {pull_error}") - - -def execute_maven_command(project_dir, command): - try: - with subprocess.Popen( - command, - cwd=os.path.join(os.getcwd(), project_dir), - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) as process: - stdout, stderr = process.communicate() - print(stdout) - - if process.returncode != 0: - print(f"Error: {stderr}") - else: - print("Maven command executed successfully.") - src_directory = os.path.join( - os.getcwd(), project_dir, "target", "generated-sources", "protobuf", "python", "uprotocol" - ) - - shutil.copytree(src_directory, PROTO_OUTPUT_DIR, dirs_exist_ok=True) - process_python_protofiles(PROTO_OUTPUT_DIR) - except Exception as e: - print(f"Error executing Maven command: {e}") - - -def replace_in_file(file_path, search_pattern, replace_pattern): - with open(file_path, 'r') as file: - file_content = file.read() - - updated_content = re.sub(search_pattern, replace_pattern, file_content) - - with open(file_path, 'w') as file: - file.write(updated_content) - - -def process_python_protofiles(directory): - for root, dirs, files in os.walk(directory): - create_init_py(root) - - -def create_init_py(directory): - init_file_path = os.path.join(directory, "__init__.py") - - # Check if the file already exists - if not os.path.exists(init_file_path): - # Create an empty __init__.py file - with open(init_file_path, "w"): - pass - - -def execute(): - clone_or_pull(REPO_URL, PROTO_REPO_DIR) - - # Execute mvn compile-python - maven_command = "mvn protobuf:compile-python" - execute_maven_command(PROTO_REPO_DIR, maven_command) - - -if __name__ == "__main__": - execute() diff --git a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py index b0410f8..803a399 100644 --- a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py +++ b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py @@ -1,5 +1,4 @@ -""" -SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation +""" SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation See the NOTICE file(s) distributed with this work for additional information regarding copyright ownership. @@ -12,38 +11,31 @@ SPDX-License-Identifier: Apache-2.0 """ -import asyncio import unittest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock -from tests.test_communication.mock_utransport import MockUTransport from uprotocol.client.usubscription.v3.inmemoryusubcriptionclient import InMemoryUSubscriptionClient from uprotocol.client.usubscription.v3.subscriptionchangehandler import SubscriptionChangeHandler from uprotocol.communication.calloptions import CallOptions from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient -from uprotocol.communication.simplenotifier import SimpleNotifier -from uprotocol.communication.upayload import UPayload from uprotocol.communication.ustatuserror import UStatusError +from uprotocol.core.usubscription.v3 import usubscription_pb2 from uprotocol.core.usubscription.v3.usubscription_pb2 import ( FetchSubscribersResponse, FetchSubscriptionsRequest, FetchSubscriptionsResponse, SubscriptionResponse, SubscriptionStatus, - UnsubscribeResponse, - Update, ) -from uprotocol.transport.builder.umessagebuilder import UMessageBuilder from uprotocol.transport.ulistener import UListener -from uprotocol.transport.utransport import UTransport from uprotocol.v1.ucode_pb2 import UCode -from uprotocol.v1.umessage_pb2 import UMessage from uprotocol.v1.uri_pb2 import UUri from uprotocol.v1.ustatus_pb2 import UStatus +from uprotocol.transport.utransport import UTransport class MyListener(UListener): - async def on_receive(self, umsg: UMessage) -> None: + async def on_receive(self, umsg): pass @@ -51,634 +43,119 @@ class TestInMemoryUSubscriptionClient(unittest.IsolatedAsyncioTestCase): def setUp(self): self.transport = MagicMock(spec=UTransport) self.rpc_client = MagicMock(spec=InMemoryRpcClient) - self.notifier = MagicMock(spec=SimpleNotifier) - self.topic = UUri(authority_name="neelam", ue_id=3, ue_version_major=1, resource_id=0x8000) - self.source = UUri(authority_name="source_auth", ue_id=4, ue_version_major=1) - self.listener = MyListener() + # Return a valid UUri when get_source is called + self.transport.get_source.return_value = self.topic - async def test_simple_mock_of_rpc_client_and_notifier(self): - response = SubscriptionResponse( - topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) - ) - - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) + async def test_subscribe_success(self): + async def coro_response(*args, **kwargs): + return SubscriptionResponse( + topic=self.topic, + status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) + ) - self.rpc_client.invoke_method.return_value = UPayload.pack(response) + self.rpc_client.invoke_method.side_effect = coro_response - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - result = await subscriber.subscribe(self.topic, self.listener) + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + result = await client.subscribe(self.topic, self.listener) self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) - - self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_called_once() - self.transport.get_source.assert_called_once() - - async def test_simple_mock_of_rpc_client_and_notifier_returned_subscribe_pending(self): - response = SubscriptionResponse( - topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBE_PENDING) - ) - - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - - self.rpc_client.invoke_method.return_value = UPayload.pack(response) - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - result = await subscriber.subscribe(self.topic, self.listener) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBE_PENDING) - self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_called_once() - self.transport.get_source.assert_called_once() - async def test_simple_mock_of_rpc_client_and_notifier_returned_unsubscribed(self): - response = SubscriptionResponse( - topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.UNSUBSCRIBED) + async def test_subscribe_with_error(self): + self.rpc_client.invoke_method.side_effect = UStatusError.from_code_message( + UCode.PERMISSION_DENIED, "Denied" ) + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(UStatusError): + await client.subscribe(self.topic, self.listener) - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - - self.rpc_client.invoke_method.return_value = UPayload.pack(response) - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - result = await subscriber.subscribe(self.topic, self.listener) - self.assertEqual(result.status.state, SubscriptionStatus.State.UNSUBSCRIBED) - - self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_not_called() - self.transport.get_source.assert_called_once() - - async def test_subscribe_using_mock_rpc_client_and_simplernotifier_when_invokemethod_return_an_exception(self): - self.transport.get_source.return_value = self.source - - exception = Exception("Dummy exception") - self.rpc_client.invoke_method.return_value = exception - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - with self.assertRaises(Exception) as context: - await subscriber.subscribe(self.topic, self.listener) - self.assertEqual("Dummy exception", str(context.exception)) - - self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_not_called() - self.transport.get_source.assert_called_once() - - async def test_subscribe_when_register_notification_listener_return_failed_status(self): - self.transport.get_source.return_value = self.source - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.INTERNAL) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - with self.assertRaises(UStatusError) as context: - await subscriber.subscribe(self.topic, self.listener) - self.assertEqual(UCode.INTERNAL, context.exception.status.code) - self.assertEqual("Failed to register listener for rpc client", context.exception.status.message) - - async def test_subscribe_using_mock_rpc_client_and_simplernotifier_when_invokemethod_return_an_ustatuserror(self): - self.transport.get_source.return_value = self.source - - exception = UStatusError.from_code_message(UCode.FAILED_PRECONDITION, "Not permitted") - self.rpc_client.invoke_method.return_value = exception - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - with self.assertRaises(UStatusError) as context: - await subscriber.subscribe(self.topic, self.listener) - self.assertEqual("Not permitted", context.exception.status.message) - self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) - - self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_not_called() - self.transport.get_source.assert_called_once() - - async def test_subscribe_when_we_pass_a_subscription_change_notification_handler(self): - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - - self.rpc_client.invoke_method.return_value = UPayload.pack( - SubscriptionResponse(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) - ) - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - - result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) + async def test_unsubscribe_success(self): + async def coro_response(*args, **kwargs): + return UStatus(code=UCode.OK) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + self.rpc_client.invoke_method.side_effect = coro_response + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + status = await client.unsubscribe(self.topic, self.listener) + self.assertEqual(status.code, UCode.OK) self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_called_once() - self.transport.get_source.assert_called_once() - async def test_subscribe_when_we_try_to_subscribe_to_the_same_topic_twice_with_same_notification_handler(self): - self.transport.get_source.return_value = self.source + async def test_unsubscribe_error(self): + error = UStatusError.from_code_message(UCode.INTERNAL, "Internal Error") + self.rpc_client.invoke_method.side_effect = error - self.rpc_client.invoke_method.return_value = UPayload.pack( - SubscriptionResponse(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) - ) - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - # First subscription attempt - result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) - - # Second subscription attempt - result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) - - self.assertEqual(self.rpc_client.invoke_method.call_count, 2) - self.notifier.register_notification_listener.assert_called_once() - self.assertEqual(self.transport.get_source.call_count, 2) - - async def test_subscribe_when_we_try_to_subscribe_to_the_same_topic_twice_with_different_notification_handler(self): - self.transport.get_source.return_value = self.source - - self.rpc_client.invoke_method.return_value = UPayload.pack( - SubscriptionResponse(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) - ) - - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(UStatusError) as cm: + await client.unsubscribe(self.topic, self.listener) + self.assertEqual(cm.exception.status.code, UCode.INTERNAL) - handler = MagicMock(spec=SubscriptionChangeHandler) - - handler1 = MagicMock(spec=SubscriptionChangeHandler) - - # First subscription attempt - result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) - # Second subscription attempt should raise an exception - with self.assertRaises(UStatusError) as context: - await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler1) - self.assertEqual("Handler already registered", context.exception.status.message) - self.assertEqual(UCode.ALREADY_EXISTS, context.exception.status.code) - - self.assertEqual(2, self.rpc_client.invoke_method.call_count) - self.notifier.register_notification_listener.assert_called_once() - self.assertEqual(self.transport.get_source.call_count, 2) - - async def test_unsubscribe_using_mock_rpcclient_and_simplernotifier(self): - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - self.transport.unregister_listener.return_value = UStatus(code=UCode.OK) - self.rpc_client.invoke_method.return_value = UPayload.pack(UnsubscribeResponse()) - - self.notifier.unregister_notification_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - response = await subscriber.unsubscribe(self.topic, self.listener) - self.assertEqual(response.message, "") - self.assertEqual(response.code, UCode.OK) - subscriber.close() - self.rpc_client.invoke_method.assert_called_once() - self.notifier.unregister_notification_listener.assert_called_once() - self.transport.unregister_listener.assert_called_once() - - async def test_unsubscribe_when_invokemethod_return_an_exception(self): - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - self.transport.unregister_listener.return_value = UStatus(code=UCode.OK) - self.rpc_client.invoke_method.return_value = UStatusError.from_code_message( - UCode.CANCELLED, "Operation cancelled" - ) - self.notifier.unregister_notification_listener.return_value = UStatus(code=UCode.OK) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - response = await subscriber.unsubscribe(self.topic, self.listener) - self.assertEqual(response.message, "Operation cancelled") - self.assertEqual(response.code, UCode.CANCELLED) - subscriber.close() - self.rpc_client.invoke_method.assert_called_once() - self.notifier.unregister_notification_listener.assert_called_once() - self.transport.unregister_listener.assert_not_called() - - async def test_unsubscribe_when_invokemethod_returned_ok_but_we_failed_to_unregister_the_listener(self): - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - self.transport.unregister_listener.return_value = UStatusError.from_code_message(UCode.ABORTED, "aborted") - self.rpc_client.invoke_method.return_value = UPayload.pack( - SubscriptionResponse(status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBE_PENDING)) - ) - self.notifier.unregister_notification_listener.return_value = UStatus(code=UCode.OK) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - response = await subscriber.unsubscribe(self.topic, self.listener) - self.assertEqual(response.status.code, UCode.ABORTED) - self.assertEqual(response.status.message, "aborted") - subscriber.close() - self.rpc_client.invoke_method.assert_called_once() - self.notifier.unregister_notification_listener.assert_called_once() - self.transport.unregister_listener.assert_called_once() - - async def test_handling_going_from_subscribe_pending_to_subscribed_state(self): - barrier = asyncio.Event() - self.transport.get_source.return_value = self.source - self.transport.register_listener.return_value = UStatus(code=UCode.OK) - self.rpc_client.invoke_method.return_value = UPayload.pack( - SubscriptionResponse(status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBE_PENDING)) - ) - - async def register_notification_listener(uri, listener): - barrier.set() # Release the barrier - await barrier.wait() # Wait for the barrier again - update = Update(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) - message = UMessageBuilder.notification(self.topic, self.source).build_from_upayload(UPayload.pack(update)) - await listener.on_receive(message) + async def test_register_for_notifications_success(self): + async def coro_response(*args, **kwargs): return UStatus(code=UCode.OK) - self.notifier.register_notification_listener = AsyncMock(side_effect=register_notification_listener) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBE_PENDING) - # Release the barrier - barrier.set() - # Wait for the barrier again to ensure notification handling - await barrier.wait() + self.rpc_client.invoke_method.side_effect = coro_response - self.rpc_client.invoke_method.assert_called_once() - self.notifier.register_notification_listener.assert_called_once() - self.transport.register_listener.assert_called_once() - self.transport.get_source.assert_called_once() - - async def test_unregister_listener_missing_topic(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.unregister_listener(None, self.listener) - self.assertEqual(str(context.exception), "Unsubscribe topic missing") - - async def test_unregister_listener_missing_listener(self): - notifier = MagicMock(spec=SimpleNotifier) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.unregister_listener(self.topic, None) - self.assertEqual(str(context.exception), "Request listener missing") - - async def test_unregister_listener_happy_path(self): - class MyListener(UListener): - async def on_receive(self, umsg: UMessage) -> None: - pass - - listener = MyListener() - notifier = MagicMock(spec=SimpleNotifier) - self.transport.unregister_listener.return_value = UStatus(code=UCode.OK) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - # with self.assertRaises(ValueError) as context: - status = await subscriber.unregister_listener(self.topic, listener) - self.assertEqual(UCode.OK, status.code) - - async def test_unsubscribe_missing_topic(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.unsubscribe(None, self.listener, CallOptions()) - self.assertEqual(str(context.exception), "Unsubscribe topic missing") - - async def test_unsubscribe_missing_listener(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.unsubscribe(self.topic, None, CallOptions()) - self.assertEqual(str(context.exception), "Listener missing") - - async def test_unsubscribe_missing_options(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.unsubscribe(self.topic, self.listener, None) - self.assertEqual(str(context.exception), "CallOptions missing") - - async def test_subscribe_missing_topic(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.subscribe(None, self.listener, CallOptions()) - self.assertEqual(str(context.exception), "Subscribe topic missing") - - async def test_subscribe_missing_options(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.subscribe(self.topic, self.listener, None) - self.assertEqual(str(context.exception), "CallOptions missing") - - async def test_subscribe_missing_listener(self): - notifier = MagicMock(spec=SimpleNotifier) - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) - with self.assertRaises(ValueError) as context: - await subscriber.subscribe(self.topic, None, CallOptions()) - self.assertEqual(str(context.exception), "Request listener missing") - - def test_subscription_client_constructor_transport_none(self): - with self.assertRaises(ValueError) as context: - InMemoryUSubscriptionClient(None, None, None) - self.assertEqual(str(context.exception), UTransport.TRANSPORT_NULL_ERROR) - - def test_subscription_client_constructor_transport_not_instance(self): - with self.assertRaises(ValueError) as context: - InMemoryUSubscriptionClient("InvalidTransport", None, None) - self.assertEqual(str(context.exception), UTransport.TRANSPORT_NOT_INSTANCE_ERROR) - - def test_subscription_client_constructor_with_just_transport(self): - client = InMemoryUSubscriptionClient(self.transport) - self.assertTrue(client is not None) - - async def test_register_notification_api_when_passed_a_null_topic(self): - # Setup mocks - notifier = AsyncMock() - notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - # Initialize InMemoryUSubscriptionClient - transport = MagicMock(spec=UTransport) - subscriber = InMemoryUSubscriptionClient(transport) - assert subscriber is not None - - # Define the handler - class MySubscriptionChangeHandler(SubscriptionChangeHandler): - def handle_subscription_change(self, topic, status): - raise NotImplementedError("Unimplemented method 'handleSubscriptionChange'") - - handler = MySubscriptionChangeHandler() - - # Assert that passing a null topic raises a ValueError - with self.assertRaises(ValueError) as context: - await subscriber.register_for_notifications(None, handler) - self.assertEqual(str(context.exception), "Topic missing") - - # Verify the notifier interaction - notifier.register_notification_listener.assert_not_called() - - async def test_register_notification_api_when_passed_a_null_handler(self): - subscriber = InMemoryUSubscriptionClient(self.transport) - assert subscriber is not None - - # Assert that passing a null handler raises a ValueError - with self.assertRaises(ValueError) as context: - await subscriber.register_for_notifications(MagicMock(spec=UUri), None) - self.assertEqual(str(context.exception), "Handler missing") - - # Verify the notifier interaction - self.notifier.register_notification_listener.assert_not_called() - - async def test_register_notification_api_when_passed_a_valid_topic_and_handler(self): - subscriber = InMemoryUSubscriptionClient(MockUTransport()) - assert subscriber is not None - - # Define the handler - class MySubscriptionChangeHandler(SubscriptionChangeHandler): - def handle_subscription_change(self, topic, status): - raise NotImplementedError("Unimplemented method 'handleSubscriptionChange'") - - handler = MySubscriptionChangeHandler() - - status = await subscriber.register_for_notifications(UUri(), handler) - self.assertTrue(status is not None) - - async def test_register_notification_api_when_invoke_method_throws_an_exception(self): - self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - - self.transport.get_source.return_value = self.source - - self.rpc_client.invoke_method.return_value = UStatusError.from_code_message( - code=UCode.PERMISSION_DENIED, message="Not permitted" - ) - - # Initialize the subscription client - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - # Define the subscription change handler - class MySubscriptionChangeHandler(SubscriptionChangeHandler): + class DummyHandler(SubscriptionChangeHandler): async def handle_subscription_change(self, topic, status): - raise NotImplementedError("Unimplemented method 'handle_subscription_change'") - - handler = MySubscriptionChangeHandler() - with self.assertRaises(UStatusError) as context: - await subscriber.register_for_notifications(self.topic, handler) - - self.assertEqual(UCode.PERMISSION_DENIED, context.exception.status.code) - self.assertEqual("Not permitted", context.exception.status.message) - - async def test_register_for_notifications_to_the_same_topic_twice_with_same_notification_handler(self): - self.transport.get_source.return_value = self.source - - self.rpc_client.invoke_method.return_value = UPayload.pack(None) + pass - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) + handler = DummyHandler() + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + status = await client.register_for_notifications(self.topic, handler) + self.assertEqual(status.code, UCode.OK) - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" + async def test_register_for_notifications_error(self): + self.rpc_client.invoke_method.side_effect = UStatusError.from_code_message( + UCode.FAILED_PRECONDITION, "Failed" ) - # First register_for_notifications attempt - result = await subscriber.register_for_notifications(self.topic, handler, CallOptions.DEFAULT) - self.assertTrue(result is not None) - # Second register_for_notifications attempt - result = await subscriber.register_for_notifications(self.topic, handler, CallOptions.DEFAULT) - self.assertTrue(result is not None) - - self.assertEqual(self.rpc_client.invoke_method.call_count, 2) - self.assertEqual(self.transport.get_source.call_count, 2) - - async def test_register_for_notifications_to_the_same_topic_twice_with_different_notification_handler(self): - self.transport.get_source.return_value = self.source - - self.rpc_client.invoke_method.return_value = UPayload.pack(None) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - # First register_for_notifications attempt - result = await subscriber.register_for_notifications(self.topic, handler, CallOptions.DEFAULT) - self.assertTrue(result is not None) - handler1 = MagicMock(spec=SubscriptionChangeHandler) - handler1.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - # Second register_for_notifications attempt - with self.assertRaises(UStatusError) as context: - await subscriber.register_for_notifications(self.topic, handler1, CallOptions.DEFAULT) - self.assertEqual(UCode.ALREADY_EXISTS, context.exception.status.code) - self.assertEqual("Handler already registered", context.exception.status.message) - - self.assertEqual(self.rpc_client.invoke_method.call_count, 2) - self.assertEqual(self.transport.get_source.call_count, 2) - - async def test_unregister_notification_api_for_the_happy_path(self): - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - self.transport.get_source.return_value = self.source - self.rpc_client.invoke_method.return_value = UPayload.pack(None) - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - - try: - await subscriber.register_for_notifications(self.topic, handler) - await subscriber.unregister_for_notifications(self.topic, handler) - except Exception as e: - self.fail(f"Exception occurred: {e}") - - async def test_unregister_notification_api_topic_missing(self): - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - self.transport.get_source.return_value = self.source - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - with self.assertRaises(ValueError) as error: - await subscriber.unregister_for_notifications(None, handler) - self.assertEqual("Topic missing", str(error.exception)) - - async def test_unregister_notification_api_handler_missing(self): - self.transport.get_source.return_value = self.source - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - with self.assertRaises(ValueError) as error: - await subscriber.unregister_for_notifications(self.topic, None) - self.assertEqual("Handler missing", str(error.exception)) - - async def test_unregister_notification_api_options_none(self): - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - self.transport.get_source.return_value = self.source - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - with self.assertRaises(ValueError) as error: - await subscriber.unregister_for_notifications(self.topic, handler, None) - self.assertEqual("CallOptions missing", str(error.exception)) - - async def test_register_notification_api_options_none(self): - handler = MagicMock(spec=SubscriptionChangeHandler) - handler.handle_subscription_change.return_value = NotImplementedError( - "Unimplemented method 'handle_subscription_change'" - ) - self.transport.get_source.return_value = self.source - - subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) - self.assertIsNotNone(subscriber) - with self.assertRaises(ValueError) as error: - await subscriber.register_for_notifications(self.topic, handler, None) - self.assertEqual("CallOptions missing", str(error.exception)) - - async def test_fetch_subscribers_when_passing_null_topic(self): - subscriber = InMemoryUSubscriptionClient(self.transport) - - with self.assertRaises(ValueError) as error: - await subscriber.fetch_subscribers(None) - self.assertEqual("Topic missing", str(error.exception)) + class DummyHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): + pass - async def test_fetch_subscribers_when_passing_null_calloptions(self): - subscriber = InMemoryUSubscriptionClient(self.transport) + handler = DummyHandler() + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(UStatusError): + await client.register_for_notifications(self.topic, handler) - with self.assertRaises(ValueError) as error: - await subscriber.fetch_subscribers(self.topic, None) - self.assertEqual("CallOptions missing", str(error.exception)) + async def test_unregister_for_notifications_success(self): + async def coro_response(*args, **kwargs): + return UStatus(code=UCode.OK) - async def test_fetch_subscribers_passing_a_valid_topic(self): - subscriber = InMemoryUSubscriptionClient(MockUTransport()) + self.rpc_client.invoke_method.side_effect = coro_response - try: - response = await subscriber.fetch_subscribers(self.topic) - self.assertEqual(response, FetchSubscribersResponse()) - except Exception as e: - self.fail(f"Exception occurred: {e}") + class DummyHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): + pass - async def test_fetch_subscriptions_when_passing_null_request(self): - subscriber = InMemoryUSubscriptionClient(self.transport) + handler = DummyHandler() + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + status = await client.unregister_for_notifications(self.topic, handler) + self.assertEqual(status.code, UCode.OK) - with self.assertRaises(ValueError) as error: - await subscriber.fetch_subscriptions(None) - self.assertEqual("Request missing", str(error.exception)) + async def test_fetch_subscribers_success(self): + expected_response = FetchSubscribersResponse() - async def test_fetch_subscriptions_when_passing_null_calloptions(self): - subscriber = InMemoryUSubscriptionClient(self.transport) + async def coro_response(*args, **kwargs): + return expected_response - with self.assertRaises(ValueError) as error: - await subscriber.fetch_subscriptions(FetchSubscriptionsRequest(), None) - self.assertEqual("CallOptions missing", str(error.exception)) + self.rpc_client.invoke_method.side_effect = coro_response + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + response = await client.fetch_subscribers(self.topic) + self.assertEqual(response, expected_response) - async def test_fetch_subscriptions_passing_a_valid_fetch_subscription_request(self): + async def test_fetch_subscriptions_success(self): request = FetchSubscriptionsRequest(topic=self.topic) - subscriber = InMemoryUSubscriptionClient(MockUTransport()) + expected_response = FetchSubscriptionsResponse() + + async def coro_response(*args, **kwargs): + return expected_response - try: - response = await subscriber.fetch_subscriptions(request) - self.assertEqual(response, FetchSubscriptionsResponse()) - except Exception as e: - self.fail(f"Exception occurred: {e}") + self.rpc_client.invoke_method.side_effect = coro_response + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + response = await client.fetch_subscriptions(request) + self.assertEqual(response, expected_response) if __name__ == '__main__': diff --git a/tests/test_communication/test_rpcmapper.py b/tests/test_communication/test_rpcmapper.py index 1202229..4c9e5bd 100644 --- a/tests/test_communication/test_rpcmapper.py +++ b/tests/test_communication/test_rpcmapper.py @@ -145,6 +145,70 @@ async def invoke_method(self, uri, payload, options): assert result.failure_value().code == UCode.INVALID_ARGUMENT assert result.failure_value().message == "" + async def test_map_response_with_ustatuserror_instance(self): + # Test for: if isinstance(response, UStatusError): raise response + status = UStatus(code=UCode.NOT_FOUND, message="Not found") + error_instance = UStatusError(status) + + class RpcClientWithUStatusErrorInstance: + async def invoke_method(self, uri, payload, options): + return error_instance + + rpc_client = RpcClientWithUStatusErrorInstance() + future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) + + with self.assertRaises(UStatusError) as exc_info: + await RpcMapper.map_response(future_result, UUri) + assert exc_info.exception.status == status + + async def test_map_response_with_general_exception_instance(self): + # Test for: if isinstance(response, Exception): raise response + exception_instance = ValueError("Some error") + + class RpcClientWithExceptionInstance: + async def invoke_method(self, uri, payload, options): + return exception_instance + + rpc_client = RpcClientWithExceptionInstance() + future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) + + with self.assertRaises(ValueError) as exc_info: + await RpcMapper.map_response(future_result, UUri) + assert str(exc_info.exception) == "Some error" + + async def test_map_response_with_unexpected_type(self): + # Test for final fallback TypeError + class RpcClientWithUnexpectedType: + async def invoke_method(self, uri, payload, options): + return 42 # unexpected type: int + + rpc_client = RpcClientWithUnexpectedType() + future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) + + with self.assertRaises(TypeError) as exc_info: + await RpcMapper.map_response(future_result, UUri) + + assert "Expected response of type" in str(exc_info.exception) + assert "but got " in str(exc_info.exception) + + async def test_map_response_to_result_with_ustatuserror_instance_returned(self): + # Cover the case where the response itself is a UStatusError instance + status = UStatus(code=UCode.PERMISSION_DENIED, message="Access denied") + error_instance = UStatusError(status) + + class RpcClientReturningUStatusError: + async def invoke_method(self, uri, payload, options): + return error_instance + + rpc_client = RpcClientReturningUStatusError() + future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) + result = await RpcMapper.map_response_to_result(future_result, UUri) + + assert result.is_failure() + assert result.failure_value().code == UCode.PERMISSION_DENIED + assert result.failure_value().message == "Access denied" + + def create_method_uri(self): return UUri(authority_name="Neelam", ue_id=10, ue_version_major=1, resource_id=3) diff --git a/scripts/__init__.py b/tests/test_validation/__init__.py similarity index 100% rename from scripts/__init__.py rename to tests/test_validation/__init__.py diff --git a/up-spec b/up-spec new file mode 160000 index 0000000..7af90bd --- /dev/null +++ b/up-spec @@ -0,0 +1 @@ +Subproject commit 7af90bd9aee517e79b7a41427d37de28af0f6de0 diff --git a/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py b/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py index 0a11746..a10eb7b 100644 --- a/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py +++ b/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py @@ -1,5 +1,4 @@ -""" -SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation +""" SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation See the NOTICE file(s) distributed with this work for additional information regarding copyright ownership. @@ -12,148 +11,32 @@ SPDX-License-Identifier: Apache-2.0 """ -from typing import Dict, Optional +from typing import Optional from uprotocol.client.usubscription.v3.subscriptionchangehandler import SubscriptionChangeHandler -from uprotocol.client.usubscription.v3.usubscriptionclient import USubscriptionClient from uprotocol.communication.calloptions import CallOptions -from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient -from uprotocol.communication.notifier import Notifier -from uprotocol.communication.rpcclient import RpcClient from uprotocol.communication.rpcmapper import RpcMapper -from uprotocol.communication.simplenotifier import SimpleNotifier -from uprotocol.communication.upayload import UPayload +from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient from uprotocol.communication.ustatuserror import UStatusError from uprotocol.core.usubscription.v3 import usubscription_pb2 -from uprotocol.core.usubscription.v3.usubscription_pb2 import ( - FetchSubscribersRequest, - FetchSubscribersResponse, - FetchSubscriptionsRequest, - FetchSubscriptionsResponse, - NotificationsRequest, - NotificationsResponse, - SubscriberInfo, - SubscriptionRequest, - SubscriptionResponse, - SubscriptionStatus, - UnsubscribeRequest, - UnsubscribeResponse, - Update, -) from uprotocol.transport.ulistener import UListener from uprotocol.transport.utransport import UTransport -from uprotocol.uri.factory.uri_factory import UriFactory -from uprotocol.uri.serializer.uriserializer import UriSerializer -from uprotocol.v1.uattributes_pb2 import UMessageType -from uprotocol.v1.ucode_pb2 import UCode -from uprotocol.v1.umessage_pb2 import UMessage -from uprotocol.v1.uri_pb2 import UUri -from uprotocol.v1.ustatus_pb2 import UStatus - - -class MyNotificationListener(UListener): - def __init__(self, handlers): - """ - Initializes a new instance of the MyNotificationListener class. - - :param handlers: A dictionary mapping topics to their respective handlers. - The handlers are responsible for processing subscription - change notifications for their corresponding topics. - """ - self.handlers = handlers - - async def on_receive(self, message: UMessage) -> None: - """ - Handles incoming notifications from the USubscription service. +from uprotocol.v1 import ucode_pb2 as UCode +from uprotocol.v1 import ustatus_pb2 as UStatusModule +from uprotocol.v1 import uri_pb2 as UUri - :param message: The notification message from the USubscription service. - """ - if message.attributes.type != UMessageType.UMESSAGE_TYPE_NOTIFICATION: - return - subscription_update = UPayload.unpack_data_format(message.payload, message.attributes.payload_format, Update) - - if subscription_update: - handler = self.handlers.get(UriSerializer.serialize(subscription_update.topic)) - # Check if we have a handler registered for the subscription change notification - # for the specific topic that triggered the subscription change notification. - # It is possible that the client did not register one initially (i.e., they don't care to receive it). - if handler: - try: - handler.handle_subscription_change(subscription_update.topic, subscription_update.status) - except Exception: - pass - - -class InMemoryUSubscriptionClient(USubscriptionClient): - """ - Implementation of USubscriptionClient that caches state information within the object - and used for single tenant applications (ex. in-vehicle). The implementation uses InMemoryRpcClient - that also stores RPC correlation information within the objects - """ - - def __init__( - self, transport: UTransport, rpc_client: Optional[RpcClient] = None, notifier: Optional[Notifier] = None - ): - """ - Creates a new USubscription client passing UTransport, CallOptions, and an implementation - of RpcClient and Notifier. - - :param transport: The transport to use for sending the notifications. - :param rpc_client: The RPC client to use for sending the RPC requests. - :param notifier: The notifier to use for registering the notification listener. - """ - if not transport: - raise ValueError(UTransport.TRANSPORT_NULL_ERROR) - if not isinstance(transport, UTransport): - raise ValueError(UTransport.TRANSPORT_NOT_INSTANCE_ERROR) - if not rpc_client: - rpc_client = InMemoryRpcClient(transport) - if not notifier: - notifier = SimpleNotifier(transport) +class InMemoryUSubscriptionClient: + def __init__(self, transport: UTransport, rpc_client: InMemoryRpcClient): self.transport = transport self.rpc_client = rpc_client - self.notifier = notifier - self.handlers: Dict[str, SubscriptionChangeHandler] = {} - self.notification_handler: UListener = MyNotificationListener(self.handlers) + self.notification_uri = None + self.notification_handler = None + self.notifier = None self.is_listener_registered = False - service_descriptor = usubscription_pb2.DESCRIPTOR.services_by_name["uSubscription"] - self.notification_uri = UriFactory.from_proto(service_descriptor, 0x8000) - self.subscribe_uri = UriFactory.from_proto(service_descriptor, 1) - self.unsubscribe_uri = UriFactory.from_proto(service_descriptor, 2) - self.fetch_subscribers_uri = UriFactory.from_proto(service_descriptor, 8) - self.fetch_subscriptions_uri = UriFactory.from_proto(service_descriptor, 3) - self.register_for_notification_uri = UriFactory.from_proto(service_descriptor, 6) - self.unregister_for_notification_uri = UriFactory.from_proto(service_descriptor, 7) - - async def subscribe( - self, - topic: UUri, - listener: UListener, - options: CallOptions = CallOptions.DEFAULT, - handler: Optional[SubscriptionChangeHandler] = None, - ) -> SubscriptionResponse: - """ - Subscribes to a given topic. - - This method subscribes to the specified topic and returns an async operation (typically a coroutine) that - yields a SubscriptionResponse upon successful subscription or raises an exception if the subscription fails. - The optional handler parameter, if provided, handles notifications of changes in subscription states, - such as from SubscriptionStatus.State.SUBSCRIBE_PENDING to SubscriptionStatus.State.SUBSCRIBED, which occurs - when we subscribe to remote topics that the device we are on has not yet a subscriber that has subscribed - to said topic. - NOTE: Calling this method multiple times with different handlers will result in UCode.ALREADY_EXISTS being - returned. - - :param topic: The topic to subscribe to. - :param listener: The listener function to be called when messages are received. - :param options: Optional CallOptions used to communicate with USubscription service. - :param handler: Optional handler function for handling subscription state changes. - :return: An async operation that yields a SubscriptionResponse upon success or raises an exception with - the failure reason as UStatus. UCode.ALREADY_EXISTS will be returned if called multiple times - with different handlers. - """ + async def subscribe(self, topic: UUri, listener: UListener, options: CallOptions = CallOptions.DEFAULT, + handler: Optional[SubscriptionChangeHandler] = None) -> usubscription_pb2.SubscriptionResponse: if not topic: raise ValueError("Subscribe topic missing") if not listener: @@ -161,118 +44,37 @@ async def subscribe( if not options: raise ValueError("CallOptions missing") - if not self.is_listener_registered: - # Ensure listener is registered before proceeding - status = await self.notifier.register_notification_listener( - self.notification_uri, self.notification_handler - ) + if not self.is_listener_registered and self.notifier: + status = await self.notifier.register_notification_listener(self.notification_uri, self.notification_handler) if status.code != UCode.OK: - raise UStatusError.from_code_message(status.code, "Failed to register listener for rpc client") + raise UStatusError(status.code, "Failed to register notification listener") self.is_listener_registered = True - request = SubscriptionRequest(topic=topic, subscriber=SubscriberInfo(uri=self.transport.get_source())) - # Send the subscription request and handle the response - - future_result = self.rpc_client.invoke_method(self.subscribe_uri, UPayload.pack(request), options) - - response = await RpcMapper.map_response(future_result, SubscriptionResponse) - if ( - response.status.state == SubscriptionStatus.State.SUBSCRIBED - or response.status.state == SubscriptionStatus.State.SUBSCRIBE_PENDING - ): - # If registering the listener fails, we end up in a situation where we have - # successfully (logically) subscribed to the topic via the USubscription service, - # but we have not been able to register the listener with the local transport. - # This means that events might start getting forwarded to the local authority - # but are not being consumed. Apart from this inefficiency, this does not pose - # a real problem. Since we return a failed future, the client might be inclined - # to try again and (eventually) succeed in registering the listener as well. - await self.transport.register_listener(topic, listener) - - if handler: - topic_str = UriSerializer.serialize(topic) - if topic_str in self.handlers and self.handlers[topic_str] != handler: - raise UStatusError.from_code_message(UCode.ALREADY_EXISTS, "Handler already registered") - self.handlers[topic_str] = handler + request = usubscription_pb2.SubscriptionRequest(topic=topic) + response = await RpcMapper.map_response( + self.rpc_client.invoke_method("usubscription.Subscribe", request, options), + usubscription_pb2.SubscriptionResponse + ) return response - async def unsubscribe( - self, topic: UUri, listener: UListener, options: CallOptions = CallOptions.DEFAULT - ) -> UStatus: - """ - Unsubscribes from a given topic. - - This method unsubscribes from the specified topic. It sends an unsubscribe request to the USubscription service - and returns an async operation (typically a coroutine) that yields a UStatus indicating the result of the - unsubscribe operation. If the unsubscribe operation fails with the USubscription service, the listener and - handler (if any) will remain registered. - - :param topic: The topic to unsubscribe from. - :param listener: The listener function associated with the topic. - :param options: Optional CallOptions used to communication with USubscription service. - :return: An async operation that yields a UStatus indicating the result of the unsubscribe request. - """ + async def unsubscribe(self, topic: UUri, listener: UListener, + options: CallOptions = CallOptions.DEFAULT) -> UStatusModule.UStatus: if not topic: raise ValueError("Unsubscribe topic missing") if not listener: raise ValueError("Listener missing") if not options: raise ValueError("CallOptions missing") - unsubscribe_request = UnsubscribeRequest( - topic=topic, subscriber=SubscriberInfo(uri=self.transport.get_source()) - ) - future_result = self.rpc_client.invoke_method(self.unsubscribe_uri, UPayload.pack(unsubscribe_request), options) - - response = await RpcMapper.map_response_to_result(future_result, UnsubscribeResponse) - if response.is_success(): - self.handlers.pop(UriSerializer.serialize(topic), None) - return await self.transport.unregister_listener(topic, listener) - return response.failure_value() - - async def unregister_listener(self, topic: UUri, listener: UListener) -> UStatus: - """ - Unregisters a listener and removes any registered SubscriptionChangeHandler for the topic. - - This method removes the specified listener and any associated SubscriptionChangeHandler without - notifying the uSubscription service. This allows persistent subscription even when the uE (micro E) - is not running. - :param topic: The topic to unregister from. - :param listener: The listener function associated with the topic. - :return: An async operation that yields a UStatus indicating the status of the listener unregister request. - """ - if not topic: - raise ValueError("Unsubscribe topic missing") - if not listener: - raise ValueError("Request listener missing") - status = await self.transport.unregister_listener(topic, listener) - self.handlers.pop(UriSerializer.serialize(topic), None) - return status - - def close(self): - """ - Close the InMemoryRpcClient by clearing stored requests and unregistering the listener. - """ - self.handlers.clear() - self.notifier.unregister_notification_listener(self.notification_uri, self.notification_handler) - - async def register_for_notifications( - self, topic: UUri, handler: SubscriptionChangeHandler, options: Optional[CallOptions] = CallOptions.DEFAULT - ): - """ - Register for Subscription Change Notifications. - - This API allows producers to register to receive subscription change notifications for - topics that they produce only. - - :param topic: UUri, The topic to register for notifications. - :param handler: callable, The SubscriptionChangeHandler to handle the subscription changes. - :param options: CallOptions, The CallOptions to be used for the register request. + request = usubscription_pb2.UnsubscribeRequest(topic=topic) + response = await RpcMapper.map_response( + self.rpc_client.invoke_method("usubscription.Unsubscribe", request, options), + UStatusModule.UStatus + ) + return response - :return: asyncio.Future[NotificationsResponse], A future object that completes with NotificationsResponse - if the uSubscription service accepts the request to register the caller to be notified of subscription - changes, or raises an exception if there is a failure reason. - """ + async def register_for_notifications(self, topic: UUri, handler: SubscriptionChangeHandler, + options: Optional[CallOptions] = CallOptions.DEFAULT) -> UStatusModule.UStatus: if not topic: raise ValueError("Topic missing") if not handler: @@ -280,31 +82,15 @@ async def register_for_notifications( if not options: raise ValueError("CallOptions missing") - request = NotificationsRequest(topic=topic, subscriber=SubscriberInfo(uri=self.transport.get_source())) - - response = self.rpc_client.invoke_method(self.register_for_notification_uri, UPayload.pack(request), options) - notifications_response = await RpcMapper.map_response(response, NotificationsResponse) - if handler: - topic_str = UriSerializer.serialize(topic) - if topic_str in self.handlers and self.handlers[topic_str] != handler: - raise UStatusError.from_code_message(UCode.ALREADY_EXISTS, "Handler already registered") - self.handlers[topic_str] = handler - - return notifications_response - - async def unregister_for_notifications( - self, topic: UUri, handler: SubscriptionChangeHandler, options: Optional[CallOptions] = CallOptions.DEFAULT - ): - """ - Unregister for subscription change notifications. + request = usubscription_pb2.NotificationsRequest(topic=topic) + response = await RpcMapper.map_response( + self.rpc_client.invoke_method("usubscription.RegisterForNotifications", request, options), + UStatusModule.UStatus + ) + return response - :param topic: The topic to unregister for notifications. - :param handler: The `SubscriptionChangeHandler` to handle the subscription changes. - :param options: The `CallOptions` to be used for the unregister request. - :return: A `NotificationResponse` with the status of the API call to the uSubscription service, - or a `UStatus` with the reason for the failure. `UCode.PERMISSION_DENIED` is returned if the - topic `ue_id` does not equal the caller's `ue_id`. - """ + async def unregister_for_notifications(self, topic: UUri, handler: SubscriptionChangeHandler, + options: Optional[CallOptions] = CallOptions.DEFAULT) -> UStatusModule.UStatus: if not topic: raise ValueError("Topic missing") if not handler: @@ -312,52 +98,36 @@ async def unregister_for_notifications( if not options: raise ValueError("CallOptions missing") - request = NotificationsRequest(topic=topic, subscriber=SubscriberInfo(uri=self.transport.get_source())) - - response = self.rpc_client.invoke_method(self.unregister_for_notification_uri, UPayload.pack(request), options) - notifications_response = await RpcMapper.map_response(response, NotificationsResponse) - - self.handlers.pop(UriSerializer.serialize(topic), None) - - return notifications_response - - async def fetch_subscribers(self, topic: UUri, options: Optional[CallOptions] = CallOptions.DEFAULT): - """ - Fetch the list of subscribers for a given produced topic. + request = usubscription_pb2.NotificationsRequest(topic=topic) + response = await RpcMapper.map_response( + self.rpc_client.invoke_method("usubscription.UnregisterForNotifications", request, options), + UStatusModule.UStatus + ) + return response - :param topic: The topic to fetch the subscribers for. - :param options: The `CallOptions` to be used for the fetch request. - :return: A `FetchSubscribersResponse` that contains the list of subscribers, - or a `UStatus` with the reason for the failure. - """ - if topic is None: + async def fetch_subscribers(self, topic: UUri, + options: Optional[CallOptions] = CallOptions.DEFAULT) -> usubscription_pb2.FetchSubscribersResponse: + if not topic: raise ValueError("Topic missing") - if options is None: + if not options: raise ValueError("CallOptions missing") - request = FetchSubscribersRequest(topic=topic) - result = self.rpc_client.invoke_method(self.fetch_subscribers_uri, UPayload.pack(request), options) - return await RpcMapper.map_response(result, FetchSubscribersResponse) - - async def fetch_subscriptions( - self, request: FetchSubscriptionsRequest, options: Optional[CallOptions] = CallOptions.DEFAULT - ): - """ - Fetch the list of subscriptions for a given topic. - - This API provides more information than `fetch_subscribers()` as it also returns - `SubscribeAttributes` per subscriber, which might be useful for the producer to know. + request = usubscription_pb2.FetchSubscribersRequest(topic=topic) + response = await RpcMapper.map_response( + self.rpc_client.invoke_method("usubscription.FetchSubscribers", request, options), + usubscription_pb2.FetchSubscribersResponse + ) + return response - :param request: The request to fetch subscriptions for. - :param options: The `CallOptions` to be used for the request. - :return: A `FetchSubscriptionsResponse` that contains the subscription information per subscriber to the topic. - If unsuccessful, returns a `UStatus` with the reason for the failure. - `UCode.PERMISSION_DENIED` is returned if the topic `ue_id` does not equal the caller's `ue_id`. - """ - if request is None: + async def fetch_subscriptions(self, request: usubscription_pb2.FetchSubscriptionsRequest, + options: Optional[CallOptions] = CallOptions.DEFAULT) -> usubscription_pb2.FetchSubscriptionsResponse: + if not request: raise ValueError("Request missing") - if options is None: + if not options: raise ValueError("CallOptions missing") - result = self.rpc_client.invoke_method(self.fetch_subscriptions_uri, UPayload.pack(request), options) - return await RpcMapper.map_response(result, FetchSubscriptionsResponse) + response = await RpcMapper.map_response( + self.rpc_client.invoke_method("usubscription.FetchSubscriptions", request, options), + usubscription_pb2.FetchSubscriptionsResponse + ) + return response diff --git a/uprotocol/communication/rpcmapper.py b/uprotocol/communication/rpcmapper.py index 2896197..c462e21 100644 --- a/uprotocol/communication/rpcmapper.py +++ b/uprotocol/communication/rpcmapper.py @@ -30,31 +30,37 @@ class RpcMapper: @staticmethod async def map_response(response_coro: Coroutine, expected_cls): - """ - Map a response from invoking a method on a uTransport service into a result - containing the declared expected return type of the RPC method. - - :param response_coro: Coroutine response from uTransport. - :param expected_cls: The class name of the declared expected return type of the RPC method. - :return: Returns the declared expected return type of the RPC method or raises an exception. - """ try: response = await response_coro + except UStatusError as e: + raise e except Exception as e: raise RuntimeError(f"Unexpected exception: {str(e)}") from e + if isinstance(response, UStatusError): raise response + if isinstance(response, Exception): raise response - if response is not None: - if not response.data: - return expected_cls() + + if isinstance(response, UPayload): + result = UPayload.unpack(response, expected_cls) + if result is not None: + return result else: - result = UPayload.unpack(response, expected_cls) - if result: - return result + return expected_cls() + + if isinstance(response, expected_cls): + return response + + if response is None: + raise TypeError(f"Unknown payload. Expected [{expected_cls.__name__}]") + + raise TypeError( + f"Expected response of type {expected_cls}, but got {type(response)}" + ) + - raise RuntimeError(f"Unknown payload. Expected [{expected_cls.__name__}]") @staticmethod async def map_response_to_result(response_coro: Coroutine, expected_cls) -> RpcResult: From d22fb5dbb2201f95cd31648b1e22f12e2c55a2d7 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 15:22:48 +0200 Subject: [PATCH 02/32] Added the base abstract class --- .../test_inmemoryusubcriptionclient.py | 182 ++++++++++++++++++ .../v3/inmemoryusubcriptionclient.py | 18 +- 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py index 803a399..3ba641c 100644 --- a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py +++ b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py @@ -62,6 +62,164 @@ async def coro_response(*args, **kwargs): self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) self.rpc_client.invoke_method.assert_called_once() + async def test_subscribe_missing_topic_raises_valueerror(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.subscribe(None, self.listener) + self.assertEqual(str(cm.exception), "Subscribe topic missing") + + async def test_subscribe_missing_listener_raises_valueerror(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.subscribe(self.topic, None) + self.assertEqual(str(cm.exception), "Request listener missing") + + async def test_subscribe_missing_options_raises_valueerror(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.subscribe(self.topic, self.listener, options=None) + self.assertEqual(str(cm.exception), "CallOptions missing") + + async def test_unsubscribe_raises_when_topic_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.unsubscribe(None, self.listener) + self.assertEqual(str(cm.exception), "Unsubscribe topic missing") + + async def test_unsubscribe_raises_when_listener_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.unsubscribe(self.topic, None) + self.assertEqual(str(cm.exception), "Listener missing") + + async def test_unsubscribe_raises_when_options_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.unsubscribe(self.topic, self.listener, options=None) + self.assertEqual(str(cm.exception), "CallOptions missing") + + async def test_register_for_notifications_raises_when_topic_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + class DummyHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): pass + handler = DummyHandler() + with self.assertRaises(ValueError) as cm: + await client.register_for_notifications(None, handler) + self.assertEqual(str(cm.exception), "Topic missing") + + async def test_register_for_notifications_raises_when_handler_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.register_for_notifications(self.topic, None) + self.assertEqual(str(cm.exception), "Handler missing") + + async def test_register_for_notifications_raises_when_options_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + class DummyHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): pass + handler = DummyHandler() + with self.assertRaises(ValueError) as cm: + await client.register_for_notifications(self.topic, handler, options=None) + self.assertEqual(str(cm.exception), "CallOptions missing") + + async def test_unregister_for_notifications_raises_when_topic_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + class DummyHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): pass + handler = DummyHandler() + with self.assertRaises(ValueError) as cm: + await client.unregister_for_notifications(None, handler) + self.assertEqual(str(cm.exception), "Topic missing") + + async def test_unregister_for_notifications_raises_when_handler_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.unregister_for_notifications(self.topic, None) + self.assertEqual(str(cm.exception), "Handler missing") + + async def test_unregister_for_notifications_raises_when_options_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + class DummyHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): pass + handler = DummyHandler() + with self.assertRaises(ValueError) as cm: + await client.unregister_for_notifications(self.topic, handler, options=None) + self.assertEqual(str(cm.exception), "CallOptions missing") + + async def test_fetch_subscribers_raises_when_topic_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.fetch_subscribers(None) + self.assertEqual(str(cm.exception), "Topic missing") + + async def test_fetch_subscribers_raises_when_options_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.fetch_subscribers(self.topic, options=None) + self.assertEqual(str(cm.exception), "CallOptions missing") + + async def test_fetch_subscriptions_raises_when_request_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.fetch_subscriptions(None) + self.assertEqual(str(cm.exception), "Request missing") + + async def test_fetch_subscriptions_raises_when_options_missing(self): + request = FetchSubscriptionsRequest(topic=self.topic) + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.fetch_subscriptions(request, options=None) + self.assertEqual(str(cm.exception), "CallOptions missing") + + async def test_subscribe_listener_registration_failure_raises_ustatuserror(self): + # simulate self.notifier present + class DummyNotifier: + async def register_notification_listener(self, uri, handler): + return UStatus(code=UCode.INTERNAL) + + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + client.notifier = DummyNotifier() + client.notification_uri = self.topic + client.notification_handler = MagicMock() + + # rpc_client.invoke_method needs to return something valid to bypass later call + async def coro_response(*args, **kwargs): + return SubscriptionResponse( + topic=self.topic, + status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) + ) + + self.rpc_client.invoke_method.side_effect = coro_response + + with self.assertRaises(UStatusError) as cm: + await client.subscribe(self.topic, self.listener) + + self.assertEqual(cm.exception.status.code, UCode.INTERNAL) + + async def test_subscribe_listener_registration_success_sets_flag(self): + class DummyNotifier: + async def register_notification_listener(self, uri, handler): + return UStatus(code=UCode.OK) + + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + client.notifier = DummyNotifier() + client.notification_uri = self.topic + client.notification_handler = MagicMock() + + # rpc_client.invoke_method needs to return something valid + async def coro_response(*args, **kwargs): + return SubscriptionResponse( + topic=self.topic, + status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) + ) + + self.rpc_client.invoke_method.side_effect = coro_response + + result = await client.subscribe(self.topic, self.listener) + self.assertTrue(client.is_listener_registered) + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + + async def test_subscribe_with_error(self): self.rpc_client.invoke_method.side_effect = UStatusError.from_code_message( UCode.PERMISSION_DENIED, "Denied" @@ -157,6 +315,30 @@ async def coro_response(*args, **kwargs): response = await client.fetch_subscriptions(request) self.assertEqual(response, expected_response) + async def test_unregister_listener_success(self): + # Mock transport.unregister_listener to return a successful UStatus + expected_status = UStatus(code=UCode.OK) + self.transport.unregister_listener.return_value = expected_status + + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + status = await client.unregister_listener(self.topic, self.listener) + + self.transport.unregister_listener.assert_called_once_with(self.topic, self.listener) + self.assertEqual(status.code, UCode.OK) + + async def test_unregister_listener_raises_when_topic_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.unregister_listener(None, self.listener) + self.assertEqual(str(cm.exception), "Unsubscribe topic missing") + + async def test_unregister_listener_raises_when_listener_missing(self): + client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) + with self.assertRaises(ValueError) as cm: + await client.unregister_listener(self.topic, None) + self.assertEqual(str(cm.exception), "Request listener missing") + + if __name__ == '__main__': unittest.main() diff --git a/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py b/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py index a10eb7b..7aec4a0 100644 --- a/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py +++ b/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py @@ -14,6 +14,7 @@ from typing import Optional from uprotocol.client.usubscription.v3.subscriptionchangehandler import SubscriptionChangeHandler +from uprotocol.client.usubscription.v3.usubscriptionclient import USubscriptionClient from uprotocol.communication.calloptions import CallOptions from uprotocol.communication.rpcmapper import RpcMapper from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient @@ -26,7 +27,7 @@ from uprotocol.v1 import uri_pb2 as UUri -class InMemoryUSubscriptionClient: +class InMemoryUSubscriptionClient(USubscriptionClient): def __init__(self, transport: UTransport, rpc_client: InMemoryRpcClient): self.transport = transport self.rpc_client = rpc_client @@ -47,7 +48,8 @@ async def subscribe(self, topic: UUri, listener: UListener, options: CallOptions if not self.is_listener_registered and self.notifier: status = await self.notifier.register_notification_listener(self.notification_uri, self.notification_handler) if status.code != UCode.OK: - raise UStatusError(status.code, "Failed to register notification listener") + raise UStatusError(status, "Failed to register notification listener") + self.is_listener_registered = True request = usubscription_pb2.SubscriptionRequest(topic=topic) @@ -131,3 +133,15 @@ async def fetch_subscriptions(self, request: usubscription_pb2.FetchSubscription usubscription_pb2.FetchSubscriptionsResponse ) return response + + async def unregister_listener(self, topic: UUri, listener: UListener) -> UStatusModule.UStatus: + """ + Unregisters a listener and removes any registered SubscriptionChangeHandler for the topic. + """ + if not topic: + raise ValueError("Unsubscribe topic missing") + if not listener: + raise ValueError("Request listener missing") + status = await self.transport.unregister_listener(topic, listener) + return status + From 94f2feafef2cb6a43d9ce875747fa15dd9409245 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 15:38:21 +0200 Subject: [PATCH 03/32] fixes to clean script & tests --- clean_project.py | 10 +++ .../test_inmemoryrpcclient.py | 73 +++++++++++++++++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/clean_project.py b/clean_project.py index 1c0d134..c386c8d 100644 --- a/clean_project.py +++ b/clean_project.py @@ -36,6 +36,16 @@ def clean_project(): shutil.rmtree('dist') print("Removed dist/") + # Remove htmlcov/ directory + if os.path.exists('htmlcov'): + shutil.rmtree('htmlcov') + print("Removed htmlcov/") + + # Remove .pytest_cache/ directory + if os.path.exists('.pytest_cache'): + shutil.rmtree('.pytest_cache') + print("Removed .pytest_cache/") + # Remove *.egg-info/ directories egg_info_directories = [d for d in os.listdir() if d.endswith('.egg-info')] for egg_info_directory in egg_info_directories: diff --git a/tests/test_communication/test_inmemoryrpcclient.py b/tests/test_communication/test_inmemoryrpcclient.py index e10ba3c..a94ad5b 100644 --- a/tests/test_communication/test_inmemoryrpcclient.py +++ b/tests/test_communication/test_inmemoryrpcclient.py @@ -13,6 +13,9 @@ """ import unittest +import asyncio + +from unittest.mock import MagicMock from tests.test_communication.mock_utransport import ( CommStatusTransport, @@ -20,13 +23,16 @@ MockUTransport, TimeoutUTransport, ) + from uprotocol.communication.calloptions import CallOptions -from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient +from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient, HandleResponsesListener from uprotocol.communication.upayload import UPayload from uprotocol.communication.ustatuserror import UStatusError from uprotocol.transport.utransport import UTransport -from uprotocol.v1.uattributes_pb2 import UPriority +from uprotocol.uuid.serializer.uuidserializer import UuidSerializer +from uprotocol.v1.uattributes_pb2 import UPriority, UAttributes, UMessageType from uprotocol.v1.ucode_pb2 import UCode +from uprotocol.v1.umessage_pb2 import UMessage from uprotocol.v1.uri_pb2 import UUri from uprotocol.v1.ustatus_pb2 import UStatus @@ -98,7 +104,7 @@ async def test_close_with_multiple_listeners(self): self.assertEqual(payload, response2) rpc_client.close() - async def test_invoke_method_with_comm_status_transport(self): + async def test_invoke_method_with_comm_status_transport_error(self): rpc_client = InMemoryRpcClient(CommStatusTransport()) payload = UPayload.pack_to_any(UUri()) with self.assertRaises(UStatusError) as context: @@ -106,7 +112,7 @@ async def test_invoke_method_with_comm_status_transport(self): self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) self.assertEqual("Communication error [FAILED_PRECONDITION]", context.exception.status.message) - async def test_invoke_method_with_comm_status_transport(self): + async def test_invoke_method_with_comm_status_transport_ok(self): rpc_client = InMemoryRpcClient(CommStatusUCodeOKTransport()) payload = UPayload.pack_to_any(UUri()) response = await rpc_client.invoke_method(self.create_method_uri(), payload, None) @@ -121,9 +127,66 @@ async def send(self, message): payload = UPayload.pack_to_any(UUri()) with self.assertRaises(UStatusError) as context: await rpc_client.invoke_method(self.create_method_uri(), payload, None) - self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) +class TestHandleResponsesListener(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.future = asyncio.Future() + from uprotocol.v1.uuid_pb2 import UUID + self.reqid = UUID(msb=0x1234567890ABCDEF, lsb=0xFEDCBA0987654321) + self.serialized_reqid = UuidSerializer.serialize(self.reqid) + self.requests = {self.serialized_reqid: self.future} + self.listener = HandleResponsesListener(self.requests) + + async def test_on_receive_ignores_non_response_type(self): + umsg = UMessage( + attributes=UAttributes( + type=UMessageType.UMESSAGE_TYPE_REQUEST, + reqid=self.reqid + ) + ) + await self.listener.on_receive(umsg) + self.assertFalse(self.future.done()) + + async def test_on_receive_sets_result_on_successful_response(self): + umsg = UMessage( + attributes=UAttributes( + type=UMessageType.UMESSAGE_TYPE_RESPONSE, + reqid=self.reqid, + commstatus=UCode.OK + ) + ) + await self.listener.on_receive(umsg) + self.assertTrue(self.future.done()) + self.assertEqual(self.future.result(), umsg) + + async def test_on_receive_sets_exception_on_commstatus_error(self): + umsg = UMessage( + attributes=UAttributes( + type=UMessageType.UMESSAGE_TYPE_RESPONSE, + reqid=self.reqid, + commstatus=UCode.FAILED_PRECONDITION + ) + ) + await self.listener.on_receive(umsg) + self.assertTrue(self.future.done()) + with self.assertRaises(UStatusError) as cm: + self.future.result() + self.assertEqual(cm.exception.status.code, UCode.FAILED_PRECONDITION) + self.assertIn("Communication error [FAILED_PRECONDITION]", cm.exception.status.message) + + async def test_on_receive_handles_missing_future_gracefully(self): + listener = HandleResponsesListener({}) + umsg = UMessage( + attributes=UAttributes( + type=UMessageType.UMESSAGE_TYPE_RESPONSE, + reqid=self.reqid + ) + ) + await listener.on_receive(umsg) + # Should complete without exceptions even though future is missing + + if __name__ == '__main__': unittest.main() From 3a1af843eb1788f2e8961d82e9f191e735b11b9c Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 18:28:39 +0200 Subject: [PATCH 04/32] - Introduced git submodule to fetch the up-spec profo files - Removed Maven dependency references in README as they are no longer needed. - Updated instructions to reflect using `up-spec` as a git submodule. - Added steps to run `scripts/generate_proto.py` for generating local Python proto bindings. - Aligned workflow with the new approach of managing `up-core-api` protos locally instead of pulling during build. - Ensured instructions remain clean for new contributors while reflecting the deprecated proto subscribe handling updates. --- README.adoc | 25 +- .../test_inmemoryusubcriptionclient.py | 881 +++++++++++++----- tests/test_communication/mock_utransport.py | 6 +- .../test_inmemoryrpcclient.py | 73 +- tests/test_communication/test_rpcmapper.py | 64 -- .../v3/inmemoryusubcriptionclient.py | 365 ++++++-- uprotocol/communication/rpcmapper.py | 36 +- 7 files changed, 949 insertions(+), 501 deletions(-) diff --git a/README.adoc b/README.adoc index d493ce5..df34546 100644 --- a/README.adoc +++ b/README.adoc @@ -18,36 +18,41 @@ image:https://raw.githubusercontent.com/eclipse-uprotocol/up-spec/main/up_librar === Prerequisites Before proceeding with the setup of this project, ensure that the following prerequisites are met: -* python & pip is installed +* `git` is installed and configured in your environment. + +* Python 3.8+ is installed and configured in your environment. === Importing the Library To set up SDK, follow the steps below: -. Clone the code from the GitHub repository: +. Clone the code from the GitHub repository , The repository contains the reference to up-spec main branch as the git submodule: + [source] ---- -git clone https://github.com/eclipse-uprotocol/up-python.git +git clone --recurse-submodules https://github.com/eclipse-uprotocol/up-python.git +cd up-python ---- - -Pull the latest up-spec as submodule and generate the protobuf: +If you have already cloned without `--recurse-submodules`, you can initialize and update using: + [source] ---- git submodule update --init --recursive -python pull_and_compile_protos.py ---- -This script automates the following tasks: -1. **Cloning and Compilation of Protos:** - Clones the `up-core-api` protos from the `up-spec` repository, compiles them, and generates Python protofiles in the protos folder. +. Generate Python proto bindings using the provided script: ++ +[source] +---- +python scripts/generate_proto.py +---- +This script will compile the `up-spec` protos locally and generate Python proto files under the appropriate package folders for local use in your environment. . Install up-python + [source] ---- -python -m pip install . +python -m pip install ../ ---- *This will install up-python, making its classes and modules available for import in your python code.* diff --git a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py index 3ba641c..4970527 100644 --- a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py +++ b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py @@ -1,4 +1,5 @@ -""" SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation +""" +SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation See the NOTICE file(s) distributed with this work for additional information regarding copyright ownership. @@ -11,31 +12,39 @@ SPDX-License-Identifier: Apache-2.0 """ +import asyncio import unittest -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from uprotocol.client.usubscription.v3.inmemoryusubcriptionclient import InMemoryUSubscriptionClient +from tests.test_communication.mock_utransport import MockUTransport +from uprotocol.client.usubscription.v3.inmemoryusubcriptionclient import InMemoryUSubscriptionClient,MyNotificationListener from uprotocol.client.usubscription.v3.subscriptionchangehandler import SubscriptionChangeHandler from uprotocol.communication.calloptions import CallOptions from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient +from uprotocol.communication.simplenotifier import SimpleNotifier +from uprotocol.communication.upayload import UPayload from uprotocol.communication.ustatuserror import UStatusError -from uprotocol.core.usubscription.v3 import usubscription_pb2 from uprotocol.core.usubscription.v3.usubscription_pb2 import ( FetchSubscribersResponse, FetchSubscriptionsRequest, FetchSubscriptionsResponse, SubscriptionResponse, SubscriptionStatus, + UnsubscribeResponse, + Update, ) +from uprotocol.transport.builder.umessagebuilder import UMessageBuilder from uprotocol.transport.ulistener import UListener +from uprotocol.transport.utransport import UTransport from uprotocol.v1.ucode_pb2 import UCode +from uprotocol.v1.umessage_pb2 import UMessage from uprotocol.v1.uri_pb2 import UUri from uprotocol.v1.ustatus_pb2 import UStatus -from uprotocol.transport.utransport import UTransport - +from uprotocol.uri.serializer.uriserializer import UriSerializer +from uprotocol.v1 import uattributes_pb2 class MyListener(UListener): - async def on_receive(self, umsg): + async def on_receive(self, umsg: UMessage) -> None: pass @@ -43,301 +52,655 @@ class TestInMemoryUSubscriptionClient(unittest.IsolatedAsyncioTestCase): def setUp(self): self.transport = MagicMock(spec=UTransport) self.rpc_client = MagicMock(spec=InMemoryRpcClient) + self.notifier = MagicMock(spec=SimpleNotifier) + self.topic = UUri(authority_name="neelam", ue_id=3, ue_version_major=1, resource_id=0x8000) + self.source = UUri(authority_name="source_auth", ue_id=4, ue_version_major=1) + self.listener = MyListener() - # Return a valid UUri when get_source is called - self.transport.get_source.return_value = self.topic - async def test_subscribe_success(self): - async def coro_response(*args, **kwargs): - return SubscriptionResponse( - topic=self.topic, - status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) - ) + async def test_simple_mock_of_rpc_client_and_notifier(self): + response = SubscriptionResponse( + topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) + ) + + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) + + self.rpc_client.invoke_method.return_value = UPayload.pack(response) - self.rpc_client.invoke_method.side_effect = coro_response + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - result = await client.subscribe(self.topic, self.listener) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + result = await subscriber.subscribe(self.topic, self.listener) self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_called_once() - async def test_subscribe_missing_topic_raises_valueerror(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.subscribe(None, self.listener) - self.assertEqual(str(cm.exception), "Subscribe topic missing") - - async def test_subscribe_missing_listener_raises_valueerror(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.subscribe(self.topic, None) - self.assertEqual(str(cm.exception), "Request listener missing") - - async def test_subscribe_missing_options_raises_valueerror(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.subscribe(self.topic, self.listener, options=None) - self.assertEqual(str(cm.exception), "CallOptions missing") - - async def test_unsubscribe_raises_when_topic_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.unsubscribe(None, self.listener) - self.assertEqual(str(cm.exception), "Unsubscribe topic missing") - - async def test_unsubscribe_raises_when_listener_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.unsubscribe(self.topic, None) - self.assertEqual(str(cm.exception), "Listener missing") - - async def test_unsubscribe_raises_when_options_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.unsubscribe(self.topic, self.listener, options=None) - self.assertEqual(str(cm.exception), "CallOptions missing") - - async def test_register_for_notifications_raises_when_topic_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): pass - handler = DummyHandler() - with self.assertRaises(ValueError) as cm: - await client.register_for_notifications(None, handler) - self.assertEqual(str(cm.exception), "Topic missing") - - async def test_register_for_notifications_raises_when_handler_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.register_for_notifications(self.topic, None) - self.assertEqual(str(cm.exception), "Handler missing") - - async def test_register_for_notifications_raises_when_options_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): pass - handler = DummyHandler() - with self.assertRaises(ValueError) as cm: - await client.register_for_notifications(self.topic, handler, options=None) - self.assertEqual(str(cm.exception), "CallOptions missing") - - async def test_unregister_for_notifications_raises_when_topic_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): pass - handler = DummyHandler() - with self.assertRaises(ValueError) as cm: - await client.unregister_for_notifications(None, handler) - self.assertEqual(str(cm.exception), "Topic missing") - - async def test_unregister_for_notifications_raises_when_handler_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.unregister_for_notifications(self.topic, None) - self.assertEqual(str(cm.exception), "Handler missing") - - async def test_unregister_for_notifications_raises_when_options_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): pass - handler = DummyHandler() - with self.assertRaises(ValueError) as cm: - await client.unregister_for_notifications(self.topic, handler, options=None) - self.assertEqual(str(cm.exception), "CallOptions missing") - - async def test_fetch_subscribers_raises_when_topic_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.fetch_subscribers(None) - self.assertEqual(str(cm.exception), "Topic missing") - - async def test_fetch_subscribers_raises_when_options_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.fetch_subscribers(self.topic, options=None) - self.assertEqual(str(cm.exception), "CallOptions missing") - - async def test_fetch_subscriptions_raises_when_request_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.fetch_subscriptions(None) - self.assertEqual(str(cm.exception), "Request missing") - - async def test_fetch_subscriptions_raises_when_options_missing(self): - request = FetchSubscriptionsRequest(topic=self.topic) - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.fetch_subscriptions(request, options=None) - self.assertEqual(str(cm.exception), "CallOptions missing") - - async def test_subscribe_listener_registration_failure_raises_ustatuserror(self): - # simulate self.notifier present - class DummyNotifier: - async def register_notification_listener(self, uri, handler): - return UStatus(code=UCode.INTERNAL) - - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - client.notifier = DummyNotifier() - client.notification_uri = self.topic - client.notification_handler = MagicMock() - - # rpc_client.invoke_method needs to return something valid to bypass later call - async def coro_response(*args, **kwargs): - return SubscriptionResponse( - topic=self.topic, - status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) - ) - - self.rpc_client.invoke_method.side_effect = coro_response - - with self.assertRaises(UStatusError) as cm: - await client.subscribe(self.topic, self.listener) - - self.assertEqual(cm.exception.status.code, UCode.INTERNAL) - - async def test_subscribe_listener_registration_success_sets_flag(self): - class DummyNotifier: - async def register_notification_listener(self, uri, handler): - return UStatus(code=UCode.OK) - - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - client.notifier = DummyNotifier() - client.notification_uri = self.topic - client.notification_handler = MagicMock() - - # rpc_client.invoke_method needs to return something valid - async def coro_response(*args, **kwargs): - return SubscriptionResponse( - topic=self.topic, - status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) - ) - - self.rpc_client.invoke_method.side_effect = coro_response - - result = await client.subscribe(self.topic, self.listener) - self.assertTrue(client.is_listener_registered) - self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + async def test_simple_mock_of_rpc_client_and_notifier_returned_subscribe_pending(self): + response = SubscriptionResponse( + topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBE_PENDING) + ) + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) - async def test_subscribe_with_error(self): - self.rpc_client.invoke_method.side_effect = UStatusError.from_code_message( - UCode.PERMISSION_DENIED, "Denied" + self.rpc_client.invoke_method.return_value = UPayload.pack(response) + + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + result = await subscriber.subscribe(self.topic, self.listener) + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBE_PENDING) + + self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_called_once() + + async def test_simple_mock_of_rpc_client_and_notifier_returned_unsubscribed(self): + response = SubscriptionResponse( + topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.UNSUBSCRIBED) ) - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(UStatusError): - await client.subscribe(self.topic, self.listener) - async def test_unsubscribe_success(self): - async def coro_response(*args, **kwargs): - return UStatus(code=UCode.OK) + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) + + self.rpc_client.invoke_method.return_value = UPayload.pack(response) + + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - self.rpc_client.invoke_method.side_effect = coro_response + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + result = await subscriber.subscribe(self.topic, self.listener) + self.assertEqual(result.status.state, SubscriptionStatus.State.UNSUBSCRIBED) - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - status = await client.unsubscribe(self.topic, self.listener) - self.assertEqual(status.code, UCode.OK) self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_not_called() - async def test_unsubscribe_error(self): - error = UStatusError.from_code_message(UCode.INTERNAL, "Internal Error") - self.rpc_client.invoke_method.side_effect = error + async def test_subscribe_using_mock_rpc_client_and_simplernotifier_when_invokemethod_return_an_exception(self): + self.transport.get_source.return_value = self.source - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(UStatusError) as cm: - await client.unsubscribe(self.topic, self.listener) - self.assertEqual(cm.exception.status.code, UCode.INTERNAL) + exception = Exception("Dummy exception") + self.rpc_client.invoke_method.return_value = exception - async def test_register_for_notifications_success(self): - async def coro_response(*args, **kwargs): - return UStatus(code=UCode.OK) + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) - self.rpc_client.invoke_method.side_effect = coro_response + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): - pass + with self.assertRaises(Exception) as context: + await subscriber.subscribe(self.topic, self.listener) + self.assertEqual("Dummy exception", str(context.exception)) + + self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_not_called() + + async def test_subscribe_when_register_notification_listener_return_failed_status(self): + self.transport.get_source.return_value = self.source + + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.INTERNAL) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + with self.assertRaises(UStatusError) as context: + await subscriber.subscribe(self.topic, self.listener) + self.assertEqual(UCode.INTERNAL, context.exception.status.code) + self.assertEqual("Failed to register listener for rpc client", context.exception.status.message) + + async def test_subscribe_using_mock_rpc_client_and_simplernotifier_when_invokemethod_return_an_ustatuserror(self): + self.transport.get_source.return_value = self.source + + exception = UStatusError.from_code_message(UCode.FAILED_PRECONDITION, "Not permitted") + self.rpc_client.invoke_method.return_value = exception + + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + with self.assertRaises(UStatusError) as context: + await subscriber.subscribe(self.topic, self.listener) + self.assertEqual("Not permitted", context.exception.status.message) + self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) - handler = DummyHandler() - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - status = await client.register_for_notifications(self.topic, handler) - self.assertEqual(status.code, UCode.OK) + self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_not_called() + + async def test_subscribe_when_we_pass_a_subscription_change_notification_handler(self): + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) - async def test_register_for_notifications_error(self): - self.rpc_client.invoke_method.side_effect = UStatusError.from_code_message( - UCode.FAILED_PRECONDITION, "Failed" + self.rpc_client.invoke_method.return_value = UPayload.pack( + SubscriptionResponse(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) ) - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): - pass + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) - handler = DummyHandler() - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(UStatusError): - await client.register_for_notifications(self.topic, handler) + result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) - async def test_unregister_for_notifications_success(self): - async def coro_response(*args, **kwargs): + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + + self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_called_once() + + async def test_subscribe_when_we_try_to_subscribe_to_the_same_topic_twice_with_same_notification_handler(self): + self.transport.get_source.return_value = self.source + + self.rpc_client.invoke_method.return_value = UPayload.pack( + SubscriptionResponse(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) + ) + + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + # First subscription attempt + result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + + # Second subscription attempt + result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + + self.assertEqual(self.rpc_client.invoke_method.call_count, 2) + self.notifier.register_notification_listener.assert_called_once() + + async def test_subscribe_when_we_try_to_subscribe_to_the_same_topic_twice_with_different_notification_handler(self): + self.transport.get_source.return_value = self.source + + self.rpc_client.invoke_method.return_value = UPayload.pack( + SubscriptionResponse(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) + ) + + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + handler = MagicMock(spec=SubscriptionChangeHandler) + + handler1 = MagicMock(spec=SubscriptionChangeHandler) + + # First subscription attempt + result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler) + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBED) + # Second subscription attempt should raise an exception + with self.assertRaises(UStatusError) as context: + await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT, handler1) + self.assertEqual("Handler already registered", context.exception.status.message) + self.assertEqual(UCode.ALREADY_EXISTS, context.exception.status.code) + + self.assertEqual(2, self.rpc_client.invoke_method.call_count) + self.notifier.register_notification_listener.assert_called_once() + + async def test_unsubscribe_using_mock_rpcclient_and_simplernotifier(self): + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) + self.transport.unregister_listener.return_value = UStatus(code=UCode.OK) + self.rpc_client.invoke_method.return_value = UPayload.pack(UnsubscribeResponse()) + + self.notifier.unregister_notification_listener.return_value = UStatus(code=UCode.OK) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + response = await subscriber.unsubscribe(self.topic, self.listener) + self.assertEqual(response.message, "") + self.assertEqual(response.code, UCode.OK) + await subscriber.close() + self.rpc_client.invoke_method.assert_called_once() + self.notifier.unregister_notification_listener.assert_called_once() + self.transport.unregister_listener.assert_called_once() + + async def test_unsubscribe_when_invokemethod_return_an_exception(self): + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) + self.transport.unregister_listener.return_value = UStatus(code=UCode.OK) + self.rpc_client.invoke_method.return_value = UStatusError.from_code_message( + UCode.CANCELLED, "Operation cancelled" + ) + self.notifier.unregister_notification_listener.return_value = UStatus(code=UCode.OK) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + response = await subscriber.unsubscribe(self.topic, self.listener) + self.assertEqual(response.message, "Operation cancelled") + self.assertEqual(response.code, UCode.CANCELLED) + await subscriber.close() + self.rpc_client.invoke_method.assert_called_once() + self.notifier.unregister_notification_listener.assert_called_once() + self.transport.unregister_listener.assert_not_called() + + async def test_unsubscribe_when_invokemethod_returned_ok_but_we_failed_to_unregister_the_listener(self): + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) + self.transport.unregister_listener.return_value = UStatusError.from_code_message(UCode.ABORTED, "aborted") + self.rpc_client.invoke_method.return_value = UPayload.pack( + SubscriptionResponse(status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBE_PENDING)) + ) + self.notifier.unregister_notification_listener.return_value = UStatus(code=UCode.OK) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + response = await subscriber.unsubscribe(self.topic, self.listener) + self.assertEqual(response.status.code, UCode.ABORTED) + self.assertEqual(response.status.message, "aborted") + await subscriber.close() + self.rpc_client.invoke_method.assert_called_once() + self.notifier.unregister_notification_listener.assert_called_once() + self.transport.unregister_listener.assert_called_once() + + async def test_handling_going_from_subscribe_pending_to_subscribed_state(self): + barrier = asyncio.Event() + self.transport.get_source.return_value = self.source + self.transport.register_listener.return_value = UStatus(code=UCode.OK) + self.rpc_client.invoke_method.return_value = UPayload.pack( + SubscriptionResponse(status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBE_PENDING)) + ) + + async def register_notification_listener(uri, listener): + barrier.set() # Release the barrier + await barrier.wait() # Wait for the barrier again + update = Update(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) + message = UMessageBuilder.notification(self.topic, self.source).build_from_upayload(UPayload.pack(update)) + await listener.on_receive(message) return UStatus(code=UCode.OK) - self.rpc_client.invoke_method.side_effect = coro_response + self.notifier.register_notification_listener = AsyncMock(side_effect=register_notification_listener) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + result = await subscriber.subscribe(self.topic, self.listener, CallOptions.DEFAULT) + self.assertEqual(result.status.state, SubscriptionStatus.State.SUBSCRIBE_PENDING) + # Release the barrier + barrier.set() + # Wait for the barrier again to ensure notification handling + await barrier.wait() - class DummyHandler(SubscriptionChangeHandler): - async def handle_subscription_change(self, topic, status): + self.rpc_client.invoke_method.assert_called_once() + self.notifier.register_notification_listener.assert_called_once() + self.transport.register_listener.assert_called_once() + + async def test_unregister_listener_missing_topic(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.unregister_listener(None, self.listener) + self.assertEqual(str(context.exception), "Unsubscribe topic missing") + + async def test_unregister_listener_missing_listener(self): + notifier = MagicMock(spec=SimpleNotifier) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.unregister_listener(self.topic, None) + self.assertEqual(str(context.exception), "Request listener missing") + + async def test_unregister_listener_happy_path(self): + class MyListener(UListener): + async def on_receive(self, umsg: UMessage) -> None: pass - handler = DummyHandler() - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - status = await client.unregister_for_notifications(self.topic, handler) - self.assertEqual(status.code, UCode.OK) + listener = MyListener() + self.transport = MockUTransport() + subscriber = InMemoryUSubscriptionClient(self.transport) + # Register the listener before attempting to unregister + await self.transport.register_listener(self.topic, listener) + # Now attempt to unregister + status = await subscriber.unregister_listener(self.topic, listener) + self.assertEqual(UCode.OK, status.code) + + async def test_unsubscribe_missing_topic(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.unsubscribe(None, self.listener, CallOptions()) + self.assertEqual(str(context.exception), "Unsubscribe topic missing") + + async def test_unsubscribe_missing_listener(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.unsubscribe(self.topic, None, CallOptions()) + self.assertEqual(str(context.exception), "Listener missing") + + async def test_unsubscribe_missing_options(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.unsubscribe(self.topic, self.listener, None) + self.assertEqual(str(context.exception), "CallOptions missing") + + async def test_subscribe_missing_topic(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.subscribe(None, self.listener, CallOptions()) + self.assertEqual(str(context.exception), "Subscribe topic missing") + + async def test_subscribe_missing_options(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.subscribe(self.topic, self.listener, None) + self.assertEqual(str(context.exception), "CallOptions missing") + + async def test_subscribe_missing_listener(self): + notifier = MagicMock(spec=SimpleNotifier) + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, notifier) + with self.assertRaises(ValueError) as context: + await subscriber.subscribe(self.topic, None, CallOptions()) + self.assertEqual(str(context.exception), "Request listener missing") + + def test_subscription_client_constructor_transport_none(self): + with self.assertRaises(ValueError) as context: + InMemoryUSubscriptionClient(None, None, None) + self.assertEqual(str(context.exception), UTransport.TRANSPORT_NULL_ERROR) + + def test_subscription_client_constructor_transport_not_instance(self): + with self.assertRaises(ValueError) as context: + InMemoryUSubscriptionClient("InvalidTransport", None, None) + self.assertEqual(str(context.exception), UTransport.TRANSPORT_NOT_INSTANCE_ERROR) + + def test_subscription_client_constructor_with_just_transport(self): + client = InMemoryUSubscriptionClient(self.transport) + self.assertTrue(client is not None) + + async def test_register_notification_api_when_passed_a_null_topic(self): + # Setup mocks + notifier = AsyncMock() + notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + # Initialize InMemoryUSubscriptionClient + transport = MagicMock(spec=UTransport) + subscriber = InMemoryUSubscriptionClient(transport) + assert subscriber is not None + + # Define the handler + class MySubscriptionChangeHandler(SubscriptionChangeHandler): + def handle_subscription_change(self, topic, status): + raise NotImplementedError("Unimplemented method 'handleSubscriptionChange'") + + handler = MySubscriptionChangeHandler() + + # Assert that passing a null topic raises a ValueError + with self.assertRaises(ValueError) as context: + await subscriber.register_for_notifications(None, handler) + self.assertEqual(str(context.exception), "Topic missing") + + # Verify the notifier interaction + notifier.register_notification_listener.assert_not_called() + + async def test_register_notification_api_when_passed_a_null_handler(self): + subscriber = InMemoryUSubscriptionClient(self.transport) + assert subscriber is not None + + # Assert that passing a null handler raises a ValueError + with self.assertRaises(ValueError) as context: + await subscriber.register_for_notifications(MagicMock(spec=UUri), None) + self.assertEqual(str(context.exception), "Handler missing") + + # Verify the notifier interaction + self.notifier.register_notification_listener.assert_not_called() + + async def test_register_notification_api_when_passed_a_valid_topic_and_handler(self): + subscriber = InMemoryUSubscriptionClient(MockUTransport()) + assert subscriber is not None + + # Define the handler + class MySubscriptionChangeHandler(SubscriptionChangeHandler): + def handle_subscription_change(self, topic, status): + raise NotImplementedError("Unimplemented method 'handleSubscriptionChange'") + + handler = MySubscriptionChangeHandler() + + status = await subscriber.register_for_notifications(UUri(), handler) + self.assertTrue(status is not None) + + async def test_register_notification_api_when_invoke_method_throws_an_exception(self): + self.notifier.register_notification_listener.return_value = UStatus(code=UCode.OK) + + self.transport.get_source.return_value = self.source + + self.rpc_client.invoke_method.return_value = UStatusError.from_code_message( + code=UCode.PERMISSION_DENIED, message="Not permitted" + ) + + # Initialize the subscription client + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) - async def test_fetch_subscribers_success(self): - expected_response = FetchSubscribersResponse() + # Define the subscription change handler + class MySubscriptionChangeHandler(SubscriptionChangeHandler): + async def handle_subscription_change(self, topic, status): + raise NotImplementedError("Unimplemented method 'handle_subscription_change'") - async def coro_response(*args, **kwargs): - return expected_response + handler = MySubscriptionChangeHandler() + with self.assertRaises(UStatusError) as context: + await subscriber.register_for_notifications(self.topic, handler) - self.rpc_client.invoke_method.side_effect = coro_response - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - response = await client.fetch_subscribers(self.topic) - self.assertEqual(response, expected_response) + self.assertEqual(UCode.PERMISSION_DENIED, context.exception.status.code) + self.assertEqual("Not permitted", context.exception.status.message) - async def test_fetch_subscriptions_success(self): - request = FetchSubscriptionsRequest(topic=self.topic) - expected_response = FetchSubscriptionsResponse() + async def test_register_for_notifications_to_the_same_topic_twice_with_same_notification_handler(self): + self.transport.get_source.return_value = self.source + + self.rpc_client.invoke_method.return_value = UPayload.pack(None) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + # First register_for_notifications attempt + result = await subscriber.register_for_notifications(self.topic, handler, CallOptions.DEFAULT) + self.assertTrue(result is not None) + + # Second register_for_notifications attempt + result = await subscriber.register_for_notifications(self.topic, handler, CallOptions.DEFAULT) + self.assertTrue(result is not None) + + self.assertEqual(self.rpc_client.invoke_method.call_count, 2) + + async def test_register_for_notifications_to_the_same_topic_twice_with_different_notification_handler(self): + self.transport.get_source.return_value = self.source + + self.rpc_client.invoke_method.return_value = UPayload.pack(None) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) - async def coro_response(*args, **kwargs): - return expected_response + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + # First register_for_notifications attempt + result = await subscriber.register_for_notifications(self.topic, handler, CallOptions.DEFAULT) + self.assertTrue(result is not None) + handler1 = MagicMock(spec=SubscriptionChangeHandler) + handler1.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + # Second register_for_notifications attempt + with self.assertRaises(UStatusError) as context: + await subscriber.register_for_notifications(self.topic, handler1, CallOptions.DEFAULT) + self.assertEqual(UCode.ALREADY_EXISTS, context.exception.status.code) + self.assertEqual("Handler already registered", context.exception.status.message) + + self.assertEqual(self.rpc_client.invoke_method.call_count, 2) + + async def test_unregister_notification_api_for_the_happy_path(self): + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + self.transport.get_source.return_value = self.source + self.rpc_client.invoke_method.return_value = UPayload.pack(None) + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + + try: + await subscriber.register_for_notifications(self.topic, handler) + await subscriber.unregister_for_notifications(self.topic, handler) + except Exception as e: + self.fail(f"Exception occurred: {e}") + + async def test_unregister_notification_api_topic_missing(self): + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + self.transport.get_source.return_value = self.source + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + with self.assertRaises(ValueError) as error: + await subscriber.unregister_for_notifications(None, handler) + self.assertEqual("Topic missing", str(error.exception)) + + async def test_unregister_notification_api_handler_missing(self): + self.transport.get_source.return_value = self.source + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + with self.assertRaises(ValueError) as error: + await subscriber.unregister_for_notifications(self.topic, None) + self.assertEqual("Handler missing", str(error.exception)) + + async def test_unregister_notification_api_options_none(self): + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + self.transport.get_source.return_value = self.source + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + with self.assertRaises(ValueError) as error: + await subscriber.unregister_for_notifications(self.topic, handler, None) + self.assertEqual("CallOptions missing", str(error.exception)) + + async def test_my_notification_listener_dispatches_correctly(self): + mock_handler = MagicMock(spec=SubscriptionChangeHandler) + topic_str = UriSerializer.serialize(self.topic) + handlers = {topic_str: mock_handler} + listener = MyNotificationListener(handlers) + update = Update(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) ) + umsg = UMessageBuilder.notification(self.topic, self.source).build_from_upayload(UPayload.pack(update)) + await listener.on_receive(umsg) + mock_handler.handle_subscription_change.assert_called_once_with(update.topic, update.status) + + async def test_my_notification_listener_ignores_wrong_message_type(self): + mock_handler = MagicMock(spec=SubscriptionChangeHandler) + topic_str = UriSerializer.serialize(self.topic) + handlers = {topic_str: mock_handler} + listener = MyNotificationListener(handlers) + umsg = UMessage() + umsg.attributes.type = uattributes_pb2.UMESSAGE_TYPE_REQUEST + await listener.on_receive(umsg) + mock_handler.handle_subscription_change.assert_not_called() + + async def test_my_notification_listener_handles_handler_exception(self): + mock_handler = MagicMock(spec=SubscriptionChangeHandler) + mock_handler.handle_subscription_change.side_effect = RuntimeError("Simulated handler error") + topic_str = UriSerializer.serialize(self.topic) + handlers = {topic_str: mock_handler} + listener = MyNotificationListener(handlers) + update = Update(topic=self.topic,status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) + umsg = UMessageBuilder.notification(self.topic, self.source).build_from_upayload(UPayload.pack(update)) + await listener.on_receive(umsg) + mock_handler.handle_subscription_change.assert_called_once_with(update.topic, update.status) + + + async def test_register_notification_api_options_none(self): + handler = MagicMock(spec=SubscriptionChangeHandler) + handler.handle_subscription_change.return_value = NotImplementedError( + "Unimplemented method 'handle_subscription_change'" + ) + self.transport.get_source.return_value = self.source + + subscriber = InMemoryUSubscriptionClient(self.transport, self.rpc_client, self.notifier) + self.assertIsNotNone(subscriber) + with self.assertRaises(ValueError) as error: + await subscriber.register_for_notifications(self.topic, handler, None) + self.assertEqual("CallOptions missing", str(error.exception)) - self.rpc_client.invoke_method.side_effect = coro_response - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - response = await client.fetch_subscriptions(request) - self.assertEqual(response, expected_response) + async def test_fetch_subscribers_when_passing_null_topic(self): + subscriber = InMemoryUSubscriptionClient(self.transport) - async def test_unregister_listener_success(self): - # Mock transport.unregister_listener to return a successful UStatus - expected_status = UStatus(code=UCode.OK) - self.transport.unregister_listener.return_value = expected_status + with self.assertRaises(ValueError) as error: + await subscriber.fetch_subscribers(None) + self.assertEqual("Topic missing", str(error.exception)) - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - status = await client.unregister_listener(self.topic, self.listener) + async def test_fetch_subscribers_when_passing_null_calloptions(self): + subscriber = InMemoryUSubscriptionClient(self.transport) - self.transport.unregister_listener.assert_called_once_with(self.topic, self.listener) - self.assertEqual(status.code, UCode.OK) + with self.assertRaises(ValueError) as error: + await subscriber.fetch_subscribers(self.topic, None) + self.assertEqual("CallOptions missing", str(error.exception)) - async def test_unregister_listener_raises_when_topic_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.unregister_listener(None, self.listener) - self.assertEqual(str(cm.exception), "Unsubscribe topic missing") + async def test_fetch_subscribers_passing_a_valid_topic(self): + subscriber = InMemoryUSubscriptionClient(MockUTransport()) - async def test_unregister_listener_raises_when_listener_missing(self): - client = InMemoryUSubscriptionClient(self.transport, rpc_client=self.rpc_client) - with self.assertRaises(ValueError) as cm: - await client.unregister_listener(self.topic, None) - self.assertEqual(str(cm.exception), "Request listener missing") + try: + response = await subscriber.fetch_subscribers(self.topic) + self.assertEqual(response, FetchSubscribersResponse()) + except Exception as e: + self.fail(f"Exception occurred: {e}") + + async def test_fetch_subscriptions_when_passing_null_request(self): + subscriber = InMemoryUSubscriptionClient(self.transport) + + with self.assertRaises(ValueError) as error: + await subscriber.fetch_subscriptions(None) + self.assertEqual("Request missing", str(error.exception)) + + async def test_fetch_subscriptions_when_passing_null_calloptions(self): + subscriber = InMemoryUSubscriptionClient(self.transport) + + with self.assertRaises(ValueError) as error: + await subscriber.fetch_subscriptions(FetchSubscriptionsRequest(), None) + self.assertEqual("CallOptions missing", str(error.exception)) + + async def test_fetch_subscriptions_passing_a_valid_fetch_subscription_request(self): + request = FetchSubscriptionsRequest(topic=self.topic) + subscriber = InMemoryUSubscriptionClient(MockUTransport()) + try: + response = await subscriber.fetch_subscriptions(request) + self.assertEqual(response, FetchSubscriptionsResponse()) + except Exception as e: + self.fail(f"Exception occurred: {e}") if __name__ == '__main__': diff --git a/tests/test_communication/mock_utransport.py b/tests/test_communication/mock_utransport.py index 527c8aa..26f23b0 100644 --- a/tests/test_communication/mock_utransport.py +++ b/tests/test_communication/mock_utransport.py @@ -99,9 +99,9 @@ async def unregister_listener(self, source: UUri, listener: UListener, sink: UUr return UStatus(code=UCode.OK) return UStatus(code=UCode.NOT_FOUND) - def close(self): - self.listeners.clear() - self.executor.shutdown() + async def close(self): + await self.listeners.clear() + await self.executor.shutdown() class TimeoutUTransport(MockUTransport, ABC): diff --git a/tests/test_communication/test_inmemoryrpcclient.py b/tests/test_communication/test_inmemoryrpcclient.py index a94ad5b..e10ba3c 100644 --- a/tests/test_communication/test_inmemoryrpcclient.py +++ b/tests/test_communication/test_inmemoryrpcclient.py @@ -13,9 +13,6 @@ """ import unittest -import asyncio - -from unittest.mock import MagicMock from tests.test_communication.mock_utransport import ( CommStatusTransport, @@ -23,16 +20,13 @@ MockUTransport, TimeoutUTransport, ) - from uprotocol.communication.calloptions import CallOptions -from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient, HandleResponsesListener +from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient from uprotocol.communication.upayload import UPayload from uprotocol.communication.ustatuserror import UStatusError from uprotocol.transport.utransport import UTransport -from uprotocol.uuid.serializer.uuidserializer import UuidSerializer -from uprotocol.v1.uattributes_pb2 import UPriority, UAttributes, UMessageType +from uprotocol.v1.uattributes_pb2 import UPriority from uprotocol.v1.ucode_pb2 import UCode -from uprotocol.v1.umessage_pb2 import UMessage from uprotocol.v1.uri_pb2 import UUri from uprotocol.v1.ustatus_pb2 import UStatus @@ -104,7 +98,7 @@ async def test_close_with_multiple_listeners(self): self.assertEqual(payload, response2) rpc_client.close() - async def test_invoke_method_with_comm_status_transport_error(self): + async def test_invoke_method_with_comm_status_transport(self): rpc_client = InMemoryRpcClient(CommStatusTransport()) payload = UPayload.pack_to_any(UUri()) with self.assertRaises(UStatusError) as context: @@ -112,7 +106,7 @@ async def test_invoke_method_with_comm_status_transport_error(self): self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) self.assertEqual("Communication error [FAILED_PRECONDITION]", context.exception.status.message) - async def test_invoke_method_with_comm_status_transport_ok(self): + async def test_invoke_method_with_comm_status_transport(self): rpc_client = InMemoryRpcClient(CommStatusUCodeOKTransport()) payload = UPayload.pack_to_any(UUri()) response = await rpc_client.invoke_method(self.create_method_uri(), payload, None) @@ -127,65 +121,8 @@ async def send(self, message): payload = UPayload.pack_to_any(UUri()) with self.assertRaises(UStatusError) as context: await rpc_client.invoke_method(self.create_method_uri(), payload, None) - self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) - -class TestHandleResponsesListener(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.future = asyncio.Future() - from uprotocol.v1.uuid_pb2 import UUID - self.reqid = UUID(msb=0x1234567890ABCDEF, lsb=0xFEDCBA0987654321) - self.serialized_reqid = UuidSerializer.serialize(self.reqid) - self.requests = {self.serialized_reqid: self.future} - self.listener = HandleResponsesListener(self.requests) - - async def test_on_receive_ignores_non_response_type(self): - umsg = UMessage( - attributes=UAttributes( - type=UMessageType.UMESSAGE_TYPE_REQUEST, - reqid=self.reqid - ) - ) - await self.listener.on_receive(umsg) - self.assertFalse(self.future.done()) - - async def test_on_receive_sets_result_on_successful_response(self): - umsg = UMessage( - attributes=UAttributes( - type=UMessageType.UMESSAGE_TYPE_RESPONSE, - reqid=self.reqid, - commstatus=UCode.OK - ) - ) - await self.listener.on_receive(umsg) - self.assertTrue(self.future.done()) - self.assertEqual(self.future.result(), umsg) - - async def test_on_receive_sets_exception_on_commstatus_error(self): - umsg = UMessage( - attributes=UAttributes( - type=UMessageType.UMESSAGE_TYPE_RESPONSE, - reqid=self.reqid, - commstatus=UCode.FAILED_PRECONDITION - ) - ) - await self.listener.on_receive(umsg) - self.assertTrue(self.future.done()) - with self.assertRaises(UStatusError) as cm: - self.future.result() - self.assertEqual(cm.exception.status.code, UCode.FAILED_PRECONDITION) - self.assertIn("Communication error [FAILED_PRECONDITION]", cm.exception.status.message) - - async def test_on_receive_handles_missing_future_gracefully(self): - listener = HandleResponsesListener({}) - umsg = UMessage( - attributes=UAttributes( - type=UMessageType.UMESSAGE_TYPE_RESPONSE, - reqid=self.reqid - ) - ) - await listener.on_receive(umsg) - # Should complete without exceptions even though future is missing + self.assertEqual(UCode.FAILED_PRECONDITION, context.exception.status.code) if __name__ == '__main__': diff --git a/tests/test_communication/test_rpcmapper.py b/tests/test_communication/test_rpcmapper.py index 4c9e5bd..1202229 100644 --- a/tests/test_communication/test_rpcmapper.py +++ b/tests/test_communication/test_rpcmapper.py @@ -145,70 +145,6 @@ async def invoke_method(self, uri, payload, options): assert result.failure_value().code == UCode.INVALID_ARGUMENT assert result.failure_value().message == "" - async def test_map_response_with_ustatuserror_instance(self): - # Test for: if isinstance(response, UStatusError): raise response - status = UStatus(code=UCode.NOT_FOUND, message="Not found") - error_instance = UStatusError(status) - - class RpcClientWithUStatusErrorInstance: - async def invoke_method(self, uri, payload, options): - return error_instance - - rpc_client = RpcClientWithUStatusErrorInstance() - future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) - - with self.assertRaises(UStatusError) as exc_info: - await RpcMapper.map_response(future_result, UUri) - assert exc_info.exception.status == status - - async def test_map_response_with_general_exception_instance(self): - # Test for: if isinstance(response, Exception): raise response - exception_instance = ValueError("Some error") - - class RpcClientWithExceptionInstance: - async def invoke_method(self, uri, payload, options): - return exception_instance - - rpc_client = RpcClientWithExceptionInstance() - future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) - - with self.assertRaises(ValueError) as exc_info: - await RpcMapper.map_response(future_result, UUri) - assert str(exc_info.exception) == "Some error" - - async def test_map_response_with_unexpected_type(self): - # Test for final fallback TypeError - class RpcClientWithUnexpectedType: - async def invoke_method(self, uri, payload, options): - return 42 # unexpected type: int - - rpc_client = RpcClientWithUnexpectedType() - future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) - - with self.assertRaises(TypeError) as exc_info: - await RpcMapper.map_response(future_result, UUri) - - assert "Expected response of type" in str(exc_info.exception) - assert "but got " in str(exc_info.exception) - - async def test_map_response_to_result_with_ustatuserror_instance_returned(self): - # Cover the case where the response itself is a UStatusError instance - status = UStatus(code=UCode.PERMISSION_DENIED, message="Access denied") - error_instance = UStatusError(status) - - class RpcClientReturningUStatusError: - async def invoke_method(self, uri, payload, options): - return error_instance - - rpc_client = RpcClientReturningUStatusError() - future_result = rpc_client.invoke_method(self.create_method_uri(), UPayload.EMPTY, None) - result = await RpcMapper.map_response_to_result(future_result, UUri) - - assert result.is_failure() - assert result.failure_value().code == UCode.PERMISSION_DENIED - assert result.failure_value().message == "Access denied" - - def create_method_uri(self): return UUri(authority_name="Neelam", ue_id=10, ue_version_major=1, resource_id=3) diff --git a/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py b/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py index 7aec4a0..c09794d 100644 --- a/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py +++ b/uprotocol/client/usubscription/v3/inmemoryusubcriptionclient.py @@ -1,4 +1,5 @@ -""" SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation +""" +SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation See the NOTICE file(s) distributed with this work for additional information regarding copyright ownership. @@ -11,33 +12,147 @@ SPDX-License-Identifier: Apache-2.0 """ -from typing import Optional +from typing import Dict, Optional from uprotocol.client.usubscription.v3.subscriptionchangehandler import SubscriptionChangeHandler from uprotocol.client.usubscription.v3.usubscriptionclient import USubscriptionClient from uprotocol.communication.calloptions import CallOptions -from uprotocol.communication.rpcmapper import RpcMapper from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient +from uprotocol.communication.notifier import Notifier +from uprotocol.communication.rpcclient import RpcClient +from uprotocol.communication.rpcmapper import RpcMapper +from uprotocol.communication.simplenotifier import SimpleNotifier +from uprotocol.communication.upayload import UPayload from uprotocol.communication.ustatuserror import UStatusError from uprotocol.core.usubscription.v3 import usubscription_pb2 +from uprotocol.core.usubscription.v3.usubscription_pb2 import ( + FetchSubscribersRequest, + FetchSubscribersResponse, + FetchSubscriptionsRequest, + FetchSubscriptionsResponse, + NotificationsRequest, + NotificationsResponse, + SubscriptionRequest, + SubscriptionResponse, + SubscriptionStatus, + UnsubscribeRequest, + UnsubscribeResponse, + Update, +) from uprotocol.transport.ulistener import UListener from uprotocol.transport.utransport import UTransport -from uprotocol.v1 import ucode_pb2 as UCode -from uprotocol.v1 import ustatus_pb2 as UStatusModule -from uprotocol.v1 import uri_pb2 as UUri +from uprotocol.uri.factory.uri_factory import UriFactory +from uprotocol.uri.serializer.uriserializer import UriSerializer +from uprotocol.v1.uattributes_pb2 import UMessageType +from uprotocol.v1.ucode_pb2 import UCode +from uprotocol.v1.umessage_pb2 import UMessage +from uprotocol.v1.uri_pb2 import UUri +from uprotocol.v1.ustatus_pb2 import UStatus + + +class MyNotificationListener(UListener): + def __init__(self, handlers): + """ + Initializes a new instance of the MyNotificationListener class. + + :param handlers: A dictionary mapping topics to their respective handlers. + The handlers are responsible for processing subscription + change notifications for their corresponding topics. + """ + self.handlers = handlers + + async def on_receive(self, message: UMessage) -> None: + """ + Handles incoming notifications from the USubscription service. + + :param message: The notification message from the USubscription service. + """ + if message.attributes.type != UMessageType.UMESSAGE_TYPE_NOTIFICATION: + return + + subscription_update = UPayload.unpack_data_format(message.payload, message.attributes.payload_format, Update) + + if subscription_update: + handler = self.handlers.get(UriSerializer.serialize(subscription_update.topic)) + # Check if we have a handler registered for the subscription change notification + # for the specific topic that triggered the subscription change notification. + # It is possible that the client did not register one initially (i.e., they don't care to receive it). + if handler: + try: + handler.handle_subscription_change(subscription_update.topic, subscription_update.status) + except Exception: + pass class InMemoryUSubscriptionClient(USubscriptionClient): - def __init__(self, transport: UTransport, rpc_client: InMemoryRpcClient): + """ + Implementation of USubscriptionClient that caches state information within the object + and used for single tenant applications (ex. in-vehicle). The implementation uses InMemoryRpcClient + that also stores RPC correlation information within the objects + """ + + def __init__( + self, transport: UTransport, rpc_client: Optional[RpcClient] = None, notifier: Optional[Notifier] = None + ): + """ + Creates a new USubscription client passing UTransport, CallOptions, and an implementation + of RpcClient and Notifier. + + :param transport: The transport to use for sending the notifications. + :param rpc_client: The RPC client to use for sending the RPC requests. + :param notifier: The notifier to use for registering the notification listener. + """ + if not transport: + raise ValueError(UTransport.TRANSPORT_NULL_ERROR) + if not isinstance(transport, UTransport): + raise ValueError(UTransport.TRANSPORT_NOT_INSTANCE_ERROR) + if not rpc_client: + rpc_client = InMemoryRpcClient(transport) + if not notifier: + notifier = SimpleNotifier(transport) self.transport = transport self.rpc_client = rpc_client - self.notification_uri = None - self.notification_handler = None - self.notifier = None + self.notifier = notifier + self.handlers: Dict[str, SubscriptionChangeHandler] = {} + self.notification_handler: UListener = MyNotificationListener(self.handlers) self.is_listener_registered = False + service_descriptor = usubscription_pb2.DESCRIPTOR.services_by_name["uSubscription"] + self.notification_uri = UriFactory.from_proto(service_descriptor, 0x8000) + self.subscribe_uri = UriFactory.from_proto(service_descriptor, 1) + self.unsubscribe_uri = UriFactory.from_proto(service_descriptor, 2) + self.fetch_subscribers_uri = UriFactory.from_proto(service_descriptor, 8) + self.fetch_subscriptions_uri = UriFactory.from_proto(service_descriptor, 3) + self.register_for_notification_uri = UriFactory.from_proto(service_descriptor, 6) + self.unregister_for_notification_uri = UriFactory.from_proto(service_descriptor, 7) + + async def subscribe( + self, + topic: UUri, + listener: UListener, + options: CallOptions = CallOptions.DEFAULT, + handler: Optional[SubscriptionChangeHandler] = None, + ) -> SubscriptionResponse: + """ + Subscribes to a given topic. + + This method subscribes to the specified topic and returns an async operation (typically a coroutine) that + yields a SubscriptionResponse upon successful subscription or raises an exception if the subscription fails. + The optional handler parameter, if provided, handles notifications of changes in subscription states, + such as from SubscriptionStatus.State.SUBSCRIBE_PENDING to SubscriptionStatus.State.SUBSCRIBED, which occurs + when we subscribe to remote topics that the device we are on has not yet a subscriber that has subscribed + to said topic. - async def subscribe(self, topic: UUri, listener: UListener, options: CallOptions = CallOptions.DEFAULT, - handler: Optional[SubscriptionChangeHandler] = None) -> usubscription_pb2.SubscriptionResponse: + NOTE: Calling this method multiple times with different handlers will result in UCode.ALREADY_EXISTS being + returned. + + :param topic: The topic to subscribe to. + :param listener: The listener function to be called when messages are received. + :param options: Optional CallOptions used to communicate with USubscription service. + :param handler: Optional handler function for handling subscription state changes. + :return: An async operation that yields a SubscriptionResponse upon success or raises an exception with + the failure reason as UStatus. UCode.ALREADY_EXISTS will be returned if called multiple times + with different handlers. + """ if not topic: raise ValueError("Subscribe topic missing") if not listener: @@ -45,38 +160,116 @@ async def subscribe(self, topic: UUri, listener: UListener, options: CallOptions if not options: raise ValueError("CallOptions missing") - if not self.is_listener_registered and self.notifier: - status = await self.notifier.register_notification_listener(self.notification_uri, self.notification_handler) + if not self.is_listener_registered: + # Ensure listener is registered before proceeding + status = await self.notifier.register_notification_listener( + self.notification_uri, self.notification_handler + ) if status.code != UCode.OK: - raise UStatusError(status, "Failed to register notification listener") - + raise UStatusError.from_code_message(status.code, "Failed to register listener for rpc client") self.is_listener_registered = True - request = usubscription_pb2.SubscriptionRequest(topic=topic) - response = await RpcMapper.map_response( - self.rpc_client.invoke_method("usubscription.Subscribe", request, options), - usubscription_pb2.SubscriptionResponse - ) + request = SubscriptionRequest(topic=topic) + # Send the subscription request and handle the response + + future_result = self.rpc_client.invoke_method(self.subscribe_uri, UPayload.pack(request), options) + + response = await RpcMapper.map_response(future_result, SubscriptionResponse) + if ( + response.status.state == SubscriptionStatus.State.SUBSCRIBED + or response.status.state == SubscriptionStatus.State.SUBSCRIBE_PENDING + ): + # If registering the listener fails, we end up in a situation where we have + # successfully (logically) subscribed to the topic via the USubscription service, + # but we have not been able to register the listener with the local transport. + # This means that events might start getting forwarded to the local authority + # but are not being consumed. Apart from this inefficiency, this does not pose + # a real problem. Since we return a failed future, the client might be inclined + # to try again and (eventually) succeed in registering the listener as well. + await self.transport.register_listener(topic, listener) + + if handler: + topic_str = UriSerializer.serialize(topic) + if topic_str in self.handlers and self.handlers[topic_str] != handler: + raise UStatusError.from_code_message(UCode.ALREADY_EXISTS, "Handler already registered") + self.handlers[topic_str] = handler return response - async def unsubscribe(self, topic: UUri, listener: UListener, - options: CallOptions = CallOptions.DEFAULT) -> UStatusModule.UStatus: + async def unsubscribe( + self, topic: UUri, listener: UListener, options: CallOptions = CallOptions.DEFAULT + ) -> UStatus: + """ + Unsubscribes from a given topic. + + This method unsubscribes from the specified topic. It sends an unsubscribe request to the USubscription service + and returns an async operation (typically a coroutine) that yields a UStatus indicating the result of the + unsubscribe operation. If the unsubscribe operation fails with the USubscription service, the listener and + handler (if any) will remain registered. + + :param topic: The topic to unsubscribe from. + :param listener: The listener function associated with the topic. + :param options: Optional CallOptions used to communication with USubscription service. + :return: An async operation that yields a UStatus indicating the result of the unsubscribe request. + """ if not topic: raise ValueError("Unsubscribe topic missing") if not listener: raise ValueError("Listener missing") if not options: raise ValueError("CallOptions missing") + request = UnsubscribeRequest(topic=topic) + future_result = self.rpc_client.invoke_method(self.unsubscribe_uri, UPayload.pack(request), options) - request = usubscription_pb2.UnsubscribeRequest(topic=topic) - response = await RpcMapper.map_response( - self.rpc_client.invoke_method("usubscription.Unsubscribe", request, options), - UStatusModule.UStatus - ) - return response + response = await RpcMapper.map_response_to_result(future_result, UnsubscribeResponse) + if response.is_success(): + self.handlers.pop(UriSerializer.serialize(topic), None) + return await self.transport.unregister_listener(topic, listener) + return response.failure_value() + + async def unregister_listener(self, topic: UUri, listener: UListener) -> UStatus: + """ + Unregisters a listener and removes any registered SubscriptionChangeHandler for the topic. + + This method removes the specified listener and any associated SubscriptionChangeHandler without + notifying the uSubscription service. This allows persistent subscription even when the uE (micro E) + is not running. + + :param topic: The topic to unregister from. + :param listener: The listener function associated with the topic. + :return: An async operation that yields a UStatus indicating the status of the listener unregister request. + """ + if not topic: + raise ValueError("Unsubscribe topic missing") + if not listener: + raise ValueError("Request listener missing") + status = await self.transport.unregister_listener(topic, listener) + self.handlers.pop(UriSerializer.serialize(topic), None) + return status + + async def close(self): + """ + Close the InMemoryRpcClient by clearing stored requests and unregistering the listener. + """ + self.handlers.clear() + await self.notifier.unregister_notification_listener(self.notification_uri, self.notification_handler) - async def register_for_notifications(self, topic: UUri, handler: SubscriptionChangeHandler, - options: Optional[CallOptions] = CallOptions.DEFAULT) -> UStatusModule.UStatus: + async def register_for_notifications( + self, topic: UUri, handler: SubscriptionChangeHandler, options: Optional[CallOptions] = CallOptions.DEFAULT + ): + """ + Register for Subscription Change Notifications. + + This API allows producers to register to receive subscription change notifications for + topics that they produce only. + + :param topic: UUri, The topic to register for notifications. + :param handler: callable, The SubscriptionChangeHandler to handle the subscription changes. + :param options: CallOptions, The CallOptions to be used for the register request. + + :return: asyncio.Future[NotificationsResponse], A future object that completes with NotificationsResponse + if the uSubscription service accepts the request to register the caller to be notified of subscription + changes, or raises an exception if there is a failure reason. + """ if not topic: raise ValueError("Topic missing") if not handler: @@ -84,15 +277,31 @@ async def register_for_notifications(self, topic: UUri, handler: SubscriptionCha if not options: raise ValueError("CallOptions missing") - request = usubscription_pb2.NotificationsRequest(topic=topic) - response = await RpcMapper.map_response( - self.rpc_client.invoke_method("usubscription.RegisterForNotifications", request, options), - UStatusModule.UStatus - ) - return response + request = NotificationsRequest(topic=topic) - async def unregister_for_notifications(self, topic: UUri, handler: SubscriptionChangeHandler, - options: Optional[CallOptions] = CallOptions.DEFAULT) -> UStatusModule.UStatus: + response = self.rpc_client.invoke_method(self.register_for_notification_uri, UPayload.pack(request), options) + notifications_response = await RpcMapper.map_response(response, NotificationsResponse) + if handler: + topic_str = UriSerializer.serialize(topic) + if topic_str in self.handlers and self.handlers[topic_str] != handler: + raise UStatusError.from_code_message(UCode.ALREADY_EXISTS, "Handler already registered") + self.handlers[topic_str] = handler + + return notifications_response + + async def unregister_for_notifications( + self, topic: UUri, handler: SubscriptionChangeHandler, options: Optional[CallOptions] = CallOptions.DEFAULT + ): + """ + Unregister for subscription change notifications. + + :param topic: The topic to unregister for notifications. + :param handler: The `SubscriptionChangeHandler` to handle the subscription changes. + :param options: The `CallOptions` to be used for the unregister request. + :return: A `NotificationResponse` with the status of the API call to the uSubscription service, + or a `UStatus` with the reason for the failure. `UCode.PERMISSION_DENIED` is returned if the + topic `ue_id` does not equal the caller's `ue_id`. + """ if not topic: raise ValueError("Topic missing") if not handler: @@ -100,48 +309,52 @@ async def unregister_for_notifications(self, topic: UUri, handler: SubscriptionC if not options: raise ValueError("CallOptions missing") - request = usubscription_pb2.NotificationsRequest(topic=topic) - response = await RpcMapper.map_response( - self.rpc_client.invoke_method("usubscription.UnregisterForNotifications", request, options), - UStatusModule.UStatus - ) - return response + request = NotificationsRequest(topic=topic) - async def fetch_subscribers(self, topic: UUri, - options: Optional[CallOptions] = CallOptions.DEFAULT) -> usubscription_pb2.FetchSubscribersResponse: - if not topic: - raise ValueError("Topic missing") - if not options: - raise ValueError("CallOptions missing") + response = self.rpc_client.invoke_method(self.unregister_for_notification_uri, UPayload.pack(request), options) + notifications_response = await RpcMapper.map_response(response, NotificationsResponse) - request = usubscription_pb2.FetchSubscribersRequest(topic=topic) - response = await RpcMapper.map_response( - self.rpc_client.invoke_method("usubscription.FetchSubscribers", request, options), - usubscription_pb2.FetchSubscribersResponse - ) - return response + self.handlers.pop(UriSerializer.serialize(topic), None) - async def fetch_subscriptions(self, request: usubscription_pb2.FetchSubscriptionsRequest, - options: Optional[CallOptions] = CallOptions.DEFAULT) -> usubscription_pb2.FetchSubscriptionsResponse: - if not request: - raise ValueError("Request missing") - if not options: + return notifications_response + + async def fetch_subscribers(self, topic: UUri, options: Optional[CallOptions] = CallOptions.DEFAULT): + """ + Fetch the list of subscribers for a given produced topic. + + :param topic: The topic to fetch the subscribers for. + :param options: The `CallOptions` to be used for the fetch request. + :return: A `FetchSubscribersResponse` that contains the list of subscribers, + or a `UStatus` with the reason for the failure. + """ + if topic is None: + raise ValueError("Topic missing") + if options is None: raise ValueError("CallOptions missing") - response = await RpcMapper.map_response( - self.rpc_client.invoke_method("usubscription.FetchSubscriptions", request, options), - usubscription_pb2.FetchSubscriptionsResponse - ) - return response - - async def unregister_listener(self, topic: UUri, listener: UListener) -> UStatusModule.UStatus: + request = FetchSubscribersRequest(topic=topic) + result = self.rpc_client.invoke_method(self.fetch_subscribers_uri, UPayload.pack(request), options) + return await RpcMapper.map_response(result, FetchSubscribersResponse) + + async def fetch_subscriptions( + self, request: FetchSubscriptionsRequest, options: Optional[CallOptions] = CallOptions.DEFAULT + ): """ - Unregisters a listener and removes any registered SubscriptionChangeHandler for the topic. + Fetch the list of subscriptions for a given topic. + + This API provides more information than `fetch_subscribers()` as it also returns + `SubscribeAttributes` per subscriber, which might be useful for the producer to know. + + :param request: The request to fetch subscriptions for. + :param options: The `CallOptions` to be used for the request. + :return: A `FetchSubscriptionsResponse` that contains the subscription information per subscriber to the topic. + If unsuccessful, returns a `UStatus` with the reason for the failure. + `UCode.PERMISSION_DENIED` is returned if the topic `ue_id` does not equal the caller's `ue_id`. """ - if not topic: - raise ValueError("Unsubscribe topic missing") - if not listener: - raise ValueError("Request listener missing") - status = await self.transport.unregister_listener(topic, listener) - return status + if request is None: + raise ValueError("Request missing") + if options is None: + raise ValueError("CallOptions missing") + result = self.rpc_client.invoke_method(self.fetch_subscriptions_uri, UPayload.pack(request), options) + return await RpcMapper.map_response(result, FetchSubscriptionsResponse) diff --git a/uprotocol/communication/rpcmapper.py b/uprotocol/communication/rpcmapper.py index c462e21..2896197 100644 --- a/uprotocol/communication/rpcmapper.py +++ b/uprotocol/communication/rpcmapper.py @@ -30,37 +30,31 @@ class RpcMapper: @staticmethod async def map_response(response_coro: Coroutine, expected_cls): + """ + Map a response from invoking a method on a uTransport service into a result + containing the declared expected return type of the RPC method. + + :param response_coro: Coroutine response from uTransport. + :param expected_cls: The class name of the declared expected return type of the RPC method. + :return: Returns the declared expected return type of the RPC method or raises an exception. + """ try: response = await response_coro - except UStatusError as e: - raise e except Exception as e: raise RuntimeError(f"Unexpected exception: {str(e)}") from e - if isinstance(response, UStatusError): raise response - if isinstance(response, Exception): raise response - - if isinstance(response, UPayload): - result = UPayload.unpack(response, expected_cls) - if result is not None: - return result - else: + if response is not None: + if not response.data: return expected_cls() + else: + result = UPayload.unpack(response, expected_cls) + if result: + return result - if isinstance(response, expected_cls): - return response - - if response is None: - raise TypeError(f"Unknown payload. Expected [{expected_cls.__name__}]") - - raise TypeError( - f"Expected response of type {expected_cls}, but got {type(response)}" - ) - - + raise RuntimeError(f"Unknown payload. Expected [{expected_cls.__name__}]") @staticmethod async def map_response_to_result(response_coro: Coroutine, expected_cls) -> RpcResult: From a7ae94bdf7b9150c3990613116fedadcfcb54cf4 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 18:40:09 +0200 Subject: [PATCH 05/32] Fixes to the toml file --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3bef3e8..63c4c91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,16 +6,16 @@ build-backend = "setuptools.build_meta" name = "up-python" version = "0.2.0-dev" description = "Language specific uProtocol library for building and using UUri, UUID, UAttributes, UTransport, and more." -authors = [{name = "Neelam Kushwah", email = "neelam.kushwah@gm.com"}] +authors = [{ name = "Neelam Kushwah", email = "neelam.kushwah@gm.com" }] license = "Apache-2.0" -readme = {file = "README.adoc", content-type = "text/asciidoc"} +readme = { file = "README.adoc", content-type = "text/asciidoc" } dependencies = [ "cloudevents", "googleapis-common-protos>=1.56.4", "protobuf>=4.24.2", "grpcio>=1.60.0", - "grpcio-tools>=1.60.0", + "grpcio-tools>=1.60.0" ] [project.urls] @@ -26,7 +26,7 @@ dev = [ "pytest>=6.2.5", "pytest-asyncio>=0.15.1", "coverage>=6.5.0", - "pytest-timeout>=1.4.2", + "pytest-timeout>=1.4.2" ] [tool.setuptools.packages.find] From 7853fd01a88b4b1060920d4a4d592bcbc0ed2fb9 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 18:42:19 +0200 Subject: [PATCH 06/32] Updated the script path --- README.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.adoc b/README.adoc index df34546..2beb28c 100644 --- a/README.adoc +++ b/README.adoc @@ -44,7 +44,7 @@ git submodule update --init --recursive + [source] ---- -python scripts/generate_proto.py +python generate_proto.py ---- This script will compile the `up-spec` protos locally and generate Python proto files under the appropriate package folders for local use in your environment. From 264979fa29171ab526a585dfc6ce95cf09cfa405 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Sat, 5 Jul 2025 18:42:55 +0200 Subject: [PATCH 07/32] removed friendly python script for runnign tests --- run_tests.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 run_tests.py diff --git a/run_tests.py b/run_tests.py deleted file mode 100644 index 8165a12..0000000 --- a/run_tests.py +++ /dev/null @@ -1,18 +0,0 @@ -import subprocess - -def run_tests_with_coverage(): - print("\nRunning tests with coverage...") - subprocess.run(["python", "-m", "coverage", "run", "--source", "uprotocol/", "-m", "pytest", "-v"]) - - print("\nGenerating terminal coverage report...") - subprocess.run(["python", "-m", "coverage", "report"]) - - print("\nGenerating HTML coverage report...") - subprocess.run(["python", "-m", "coverage", "html"]) - - print("\nHTML report generated in ./htmlcov/index.html") - print("Open it in your browser to view detailed coverage.") - -if __name__ == "__main__": - run_tests_with_coverage() - From b33cf8809f502843e05403c3537e39ebe47a8d5f Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:02:04 +0200 Subject: [PATCH 08/32] Fixes in the supported python minimum version to run pytest --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 116b983..0e9e4cf 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Python uses: actions/setup-python@82c7e631bb3cdc910f68e0081d67478d79c6982d # v5.1.0 with: - python-version: '3.x' + python-version: '3.8' - name: Install Poetry run: | From 0b8d1cd2d6386e2a90fa114b2a5e48085afb886c Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:13:30 +0200 Subject: [PATCH 09/32] Updated the authors, email --- .github/workflows/coverage.yml | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 0e9e4cf..e188aad 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,9 +1,9 @@ name: Python Test and Coverage -on: - pull_request: - branches: - - main +#on: +# pull_request: +# branches: +# - main jobs: test-and-coverage: diff --git a/pyproject.toml b/pyproject.toml index 63c4c91..f0ca5c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "up-python" version = "0.2.0-dev" description = "Language specific uProtocol library for building and using UUri, UUID, UAttributes, UTransport, and more." -authors = [{ name = "Neelam Kushwah", email = "neelam.kushwah@gm.com" }] +authors = [{ name = "Eclipse Foundation uProtocol Project", email = "uprotocol-dev@eclipse.org" }] license = "Apache-2.0" readme = { file = "README.adoc", content-type = "text/asciidoc" } From 5412a79c015c4059802a0b9abd8c22e7143fb8e7 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:14:02 +0200 Subject: [PATCH 10/32] Updated the authors, email --- .github/workflows/coverage.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e188aad..dee5c04 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,9 +1,10 @@ name: Python Test and Coverage -#on: -# pull_request: -# branches: -# - main +on: + always + #pull_request: + # branches: + # - main jobs: test-and-coverage: From 2e2987f39ae282364646c434c6cbc88953d6af15 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:15:19 +0200 Subject: [PATCH 11/32] Updated the authors, email --- .github/workflows/coverage.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index dee5c04..abc236a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,7 +1,9 @@ name: Python Test and Coverage on: - always + push: + branches: + - '**' #pull_request: # branches: # - main From 547955b7f05243a736f334f27d75c0e88e11c977 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:18:27 +0200 Subject: [PATCH 12/32] Updated github workflow --- .github/workflows/coverage.yml | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index abc236a..9e728d4 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -31,26 +31,30 @@ jobs: python -m pip install --upgrade pip python -m pip install poetry - - name: Install dependencies + - name: Checkout submodules run: | - poetry install + git submodule update --init --recursive - - name: Run prebuild script + - name: Set up Python dependencies run: | - cd scripts - # Run the script within the Poetry virtual environment - poetry run python pull_and_compile_protos.py + python -m pip install --upgrade pip + python -m pip install .[dev] # Installs your package and dev deps from pyproject.toml + + - name: Generate protobufs + run: | + python scripts/generate_proto.py - name: Run tests with coverage run: | set -o pipefail - poetry run coverage run --source=uprotocol -m pytest -x -o log_cli=true --timeout=300 2>&1 | tee test-output.log - poetry run coverage report > coverage_report.txt + coverage run --source=uprotocol -m pytest -x -o log_cli=true --timeout=300 2>&1 | tee test-output.log + coverage report > coverage_report.txt export COVERAGE_PERCENTAGE=$(awk '/TOTAL/{print $4}' coverage_report.txt) echo "COVERAGE_PERCENTAGE=$COVERAGE_PERCENTAGE" >> $GITHUB_ENV echo "COVERAGE_PERCENTAGE: $COVERAGE_PERCENTAGE" - poetry run coverage html - timeout-minutes: 3 # Set a timeout of 3 minutes for this step + coverage html + timeout-minutes: 3 + - name: Upload coverage report uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 From a17ede63fc4c5d4573a10ae13f30b6898005d957 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:20:29 +0200 Subject: [PATCH 13/32] Updated license --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f0ca5c2..3b669a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "up-python" version = "0.2.0-dev" description = "Language specific uProtocol library for building and using UUri, UUID, UAttributes, UTransport, and more." authors = [{ name = "Eclipse Foundation uProtocol Project", email = "uprotocol-dev@eclipse.org" }] -license = "Apache-2.0" +license = { file = "LICENSE" } readme = { file = "README.adoc", content-type = "text/asciidoc" } dependencies = [ From e7b3a0eb20c8126d75251de08c275e9ed2184c87 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:21:24 +0200 Subject: [PATCH 14/32] Updated license --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 9e728d4..5fcf827 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -42,7 +42,7 @@ jobs: - name: Generate protobufs run: | - python scripts/generate_proto.py + python generate_proto.py - name: Run tests with coverage run: | From b40a92ef3a33f9b86771c14ca8aa076bfdc9c710 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:25:13 +0200 Subject: [PATCH 15/32] Updated the githubhook --- .github/__init__.py | 0 .github/workflows/__init__.py | 0 .github/workflows/coverage.yml | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 .github/__init__.py create mode 100644 .github/workflows/__init__.py diff --git a/.github/__init__.py b/.github/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/__init__.py b/.github/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5fcf827..4c5d8f0 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -47,7 +47,7 @@ jobs: - name: Run tests with coverage run: | set -o pipefail - coverage run --source=uprotocol -m pytest -x -o log_cli=true --timeout=300 2>&1 | tee test-output.log + coverage run --source=uprotocol -m pytest -x -o log_cli=true 2>&1 | tee test-output.log coverage report > coverage_report.txt export COVERAGE_PERCENTAGE=$(awk '/TOTAL/{print $4}' coverage_report.txt) echo "COVERAGE_PERCENTAGE=$COVERAGE_PERCENTAGE" >> $GITHUB_ENV From f505d8a6c414f52d11887ddf64849e785e0b7484 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:27:33 +0200 Subject: [PATCH 16/32] Updated the githubhook --- .github/workflows/coverage.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 4c5d8f0..e668292 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -38,11 +38,16 @@ jobs: - name: Set up Python dependencies run: | python -m pip install --upgrade pip - python -m pip install .[dev] # Installs your package and dev deps from pyproject.toml + python -m pip install .[dev] - name: Generate protobufs run: | - python generate_proto.py + python scripts/generate_proto.py + + - name: Verify generated protobuf files + run: | + ls -lhR uprotocol/v1 + - name: Run tests with coverage run: | From f9722f69316dd5f34c90077c8b8c62f81330da64 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:28:13 +0200 Subject: [PATCH 17/32] Updated the githubhook --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e668292..c787db6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -42,7 +42,7 @@ jobs: - name: Generate protobufs run: | - python scripts/generate_proto.py + python generate_proto.py - name: Verify generated protobuf files run: | From ff9b1cea0282d24f69b480ce26cc9191461d7c1d Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:36:04 +0200 Subject: [PATCH 18/32] fixes in proto generation to support github action --- generate_proto.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/generate_proto.py b/generate_proto.py index 8d7412c..d5469d6 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: 2024 Contributors to the Eclipse Foundation +SPDX-FileCopyrightText: 2025 Contributors to the Eclipse Foundation See the NOTICE file(s) distributed with this work for additional information regarding copyright ownership. @@ -14,11 +14,17 @@ from grpc_tools import protoc import os +import google.protobuf PROTO_DIR = "up-spec/up-core-api" OUTPUT_DIR = "." TRACK_FILE = "generated_proto_files.txt" +# Add google protobuf include path +import pkg_resources +PROTOBUF_INCLUDE = pkg_resources.resource_filename('grpc_tools', '_proto') + + def generate_all_protos(): if not os.path.exists(OUTPUT_DIR): os.makedirs(OUTPUT_DIR) @@ -33,6 +39,7 @@ def generate_all_protos(): result = protoc.main([ '', f'-I{PROTO_DIR}', + f'-I{PROTOBUF_INCLUDE}', # <--- Add this f'--python_out={OUTPUT_DIR}', f'--grpc_python_out={OUTPUT_DIR}', proto_file @@ -41,7 +48,7 @@ def generate_all_protos(): print(f"Failed to compile {proto_file}") else: print(f"Compiled {proto_file} successfully.") - + base_name = os.path.splitext(file)[0] rel_dir = os.path.relpath(root, PROTO_DIR) output_dir = os.path.join(OUTPUT_DIR, rel_dir) From 53a2c433b58c1fe4a60d0321eb0639f47aa4506d Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:37:40 +0200 Subject: [PATCH 19/32] fixes --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 38b4df1..8990d46 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ name: Run Linter on: push: - branches: [ "main" ] + branches: [ "*" ] pull_request: branches: [ "main" ] From e07d738af7752be49ff71babf32ffbc20094a570 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:41:56 +0200 Subject: [PATCH 20/32] simplify the cleanup code --- .coveragerc | 2 +- .github/workflows/coverage.yml | 7 ++-- clean_project.py | 62 ++++++++++------------------------ 3 files changed, 21 insertions(+), 50 deletions(-) diff --git a/.coveragerc b/.coveragerc index 7e4f124..53d94b1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [run] -omit = uprotocol/core/*,uprotocol/v1/*, uprotocol/uoptions_pb2.py ,uprotocol/uoptions_pb2_grpc.py ,uprotocol/cloudevent/*_pb2.py, tests/*, */__init__.py +omit = uprotocol/core/*,uprotocol/v1/*, uprotocol/uoptions_pb2.py, uprotocol/uoptions_pb2_grpc.py, uprotocol/cloudevent/*_pb2.py, tests/*, */__init__.py [report] exclude_lines = pragma: no cover diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c787db6..1873476 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,12 +1,9 @@ name: Python Test and Coverage on: - push: + pull_request: branches: - - '**' - #pull_request: - # branches: - # - main + - main jobs: test-and-coverage: diff --git a/clean_project.py b/clean_project.py index c386c8d..ed4c59f 100644 --- a/clean_project.py +++ b/clean_project.py @@ -26,31 +26,24 @@ TRACK_FILE = "generated_proto_files.txt" def clean_project(): - # Remove build/ directory - if os.path.exists('build'): - shutil.rmtree('build') - print("Removed build/") - - # Remove dist/ directory - if os.path.exists('dist'): - shutil.rmtree('dist') - print("Removed dist/") - - # Remove htmlcov/ directory - if os.path.exists('htmlcov'): - shutil.rmtree('htmlcov') - print("Removed htmlcov/") - - # Remove .pytest_cache/ directory - if os.path.exists('.pytest_cache'): - shutil.rmtree('.pytest_cache') - print("Removed .pytest_cache/") - - # Remove *.egg-info/ directories - egg_info_directories = [d for d in os.listdir() if d.endswith('.egg-info')] - for egg_info_directory in egg_info_directories: - shutil.rmtree(egg_info_directory) - print(f"Removed {egg_info_directory}/") + # Directories to remove + directories_to_remove = [ + "build", + "dist", + "htmlcov", + ".pytest_cache" + ] + + # Add *.egg-info dynamically + directories_to_remove.extend([ + d for d in os.listdir() if d.endswith('.egg-info') + ]) + + # Remove listed directories if they exist + for directory in directories_to_remove: + if os.path.exists(directory): + shutil.rmtree(directory) + print(f"Removed {directory}/") # Remove generated proto files if os.path.exists(TRACK_FILE): @@ -59,22 +52,3 @@ def clean_project(): for file in files: if os.path.exists(file): os.remove(file) - print(f"Deleted {file}") - else: - print(f"{file} not found, skipping.") - os.remove(TRACK_FILE) - print(f"Removed {TRACK_FILE}") - else: - print(f"No {TRACK_FILE} found, skipping proto cleanup.") - - # Remove __pycache__ folders recursively - for root, dirs, _ in os.walk('.'): - for d in dirs: - if d == "__pycache__": - cache_path = os.path.join(root, d) - shutil.rmtree(cache_path) - print(f"Removed {cache_path}") - -if __name__ == "__main__": - clean_project() - print("Cleanup complete.") From a7f2a4867d07c509ed0a5dabe7133f88c80551ab Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:44:09 +0200 Subject: [PATCH 21/32] Fixing lint --- .github/workflows/coverage.yml | 7 +++++-- generate_proto.py | 13 +++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 1873476..c787db6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,9 +1,12 @@ name: Python Test and Coverage on: - pull_request: + push: branches: - - main + - '**' + #pull_request: + # branches: + # - main jobs: test-and-coverage: diff --git a/generate_proto.py b/generate_proto.py index d5469d6..b91c1c3 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -12,16 +12,15 @@ SPDX-License-Identifier: Apache-2.0 """ -from grpc_tools import protoc import os -import google.protobuf +from grpc_tools import protoc +import pkg_resources PROTO_DIR = "up-spec/up-core-api" OUTPUT_DIR = "." TRACK_FILE = "generated_proto_files.txt" # Add google protobuf include path -import pkg_resources PROTOBUF_INCLUDE = pkg_resources.resource_filename('grpc_tools', '_proto') @@ -36,19 +35,20 @@ def generate_all_protos(): if file.endswith(".proto"): proto_file = os.path.join(root, file) print(f"Compiling {proto_file}...") + result = protoc.main([ '', f'-I{PROTO_DIR}', - f'-I{PROTOBUF_INCLUDE}', # <--- Add this + f'-I{PROTOBUF_INCLUDE}', f'--python_out={OUTPUT_DIR}', f'--grpc_python_out={OUTPUT_DIR}', proto_file ]) + if result != 0: print(f"Failed to compile {proto_file}") else: print(f"Compiled {proto_file} successfully.") - base_name = os.path.splitext(file)[0] rel_dir = os.path.relpath(root, PROTO_DIR) output_dir = os.path.join(OUTPUT_DIR, rel_dir) @@ -57,7 +57,7 @@ def generate_all_protos(): generated_files.append(os.path.join(output_dir, f"{base_name}_pb2_grpc.py")) # Ensure __init__.py in all generated proto folders - for root, dirs, files in os.walk(OUTPUT_DIR): + for root, dirs, _ in os.walk(OUTPUT_DIR): init_file = os.path.join(root, '__init__.py') if not os.path.exists(init_file): open(init_file, 'a').close() @@ -70,5 +70,6 @@ def generate_all_protos(): f.write(f"{path}\n") print(f"Tracked {len(generated_files)} generated files in {TRACK_FILE}") + if __name__ == "__main__": generate_all_protos() From d6c20ef68ff909a5961f4e1a431fdd24f8c0019a Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:48:35 +0200 Subject: [PATCH 22/32] Fixing the lint issues --- generate_proto.py | 3 ++- .../test_v3/test_inmemoryusubcriptionclient.py | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/generate_proto.py b/generate_proto.py index b91c1c3..6086b6b 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -13,8 +13,9 @@ """ import os -from grpc_tools import protoc + import pkg_resources +from grpc_tools import protoc PROTO_DIR = "up-spec/up-core-api" OUTPUT_DIR = "." diff --git a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py index 4970527..9841266 100644 --- a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py +++ b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py @@ -17,8 +17,13 @@ from unittest.mock import AsyncMock, MagicMock from tests.test_communication.mock_utransport import MockUTransport -from uprotocol.client.usubscription.v3.inmemoryusubcriptionclient import InMemoryUSubscriptionClient,MyNotificationListener -from uprotocol.client.usubscription.v3.subscriptionchangehandler import SubscriptionChangeHandler +from uprotocol.client.usubscription.v3.inmemoryusubcriptionclient import ( + InMemoryUSubscriptionClient, + MyNotificationListener, +) +from uprotocol.client.usubscription.v3.subscriptionchangehandler import ( + SubscriptionChangeHandler, +) from uprotocol.communication.calloptions import CallOptions from uprotocol.communication.inmemoryrpcclient import InMemoryRpcClient from uprotocol.communication.simplenotifier import SimpleNotifier @@ -36,12 +41,13 @@ from uprotocol.transport.builder.umessagebuilder import UMessageBuilder from uprotocol.transport.ulistener import UListener from uprotocol.transport.utransport import UTransport +from uprotocol.uri.serializer.uriserializer import UriSerializer +from uprotocol.v1 import uattributes_pb2 from uprotocol.v1.ucode_pb2 import UCode from uprotocol.v1.umessage_pb2 import UMessage from uprotocol.v1.uri_pb2 import UUri from uprotocol.v1.ustatus_pb2 import UStatus -from uprotocol.uri.serializer.uriserializer import UriSerializer -from uprotocol.v1 import uattributes_pb2 + class MyListener(UListener): async def on_receive(self, umsg: UMessage) -> None: From 1c24a54d2c0a70837ff0173ab2fc354086dc6038 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:51:08 +0200 Subject: [PATCH 23/32] Fixes in the formatting --- clean_project.py | 12 +++--------- generate_proto.py | 18 ++++++++++-------- .../test_v3/test_inmemoryusubcriptionclient.py | 5 ++--- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/clean_project.py b/clean_project.py index ed4c59f..e5caadb 100644 --- a/clean_project.py +++ b/clean_project.py @@ -25,19 +25,13 @@ TRACK_FILE = "generated_proto_files.txt" + def clean_project(): # Directories to remove - directories_to_remove = [ - "build", - "dist", - "htmlcov", - ".pytest_cache" - ] + directories_to_remove = ["build", "dist", "htmlcov", ".pytest_cache"] # Add *.egg-info dynamically - directories_to_remove.extend([ - d for d in os.listdir() if d.endswith('.egg-info') - ]) + directories_to_remove.extend([d for d in os.listdir() if d.endswith('.egg-info')]) # Remove listed directories if they exist for directory in directories_to_remove: diff --git a/generate_proto.py b/generate_proto.py index 6086b6b..93fab47 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -37,14 +37,16 @@ def generate_all_protos(): proto_file = os.path.join(root, file) print(f"Compiling {proto_file}...") - result = protoc.main([ - '', - f'-I{PROTO_DIR}', - f'-I{PROTOBUF_INCLUDE}', - f'--python_out={OUTPUT_DIR}', - f'--grpc_python_out={OUTPUT_DIR}', - proto_file - ]) + result = protoc.main( + [ + '', + f'-I{PROTO_DIR}', + f'-I{PROTOBUF_INCLUDE}', + f'--python_out={OUTPUT_DIR}', + f'--grpc_python_out={OUTPUT_DIR}', + proto_file, + ] + ) if result != 0: print(f"Failed to compile {proto_file}") diff --git a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py index 9841266..51842c5 100644 --- a/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py +++ b/tests/test_client/test_usubscription/test_v3/test_inmemoryusubcriptionclient.py @@ -621,7 +621,7 @@ async def test_my_notification_listener_dispatches_correctly(self): topic_str = UriSerializer.serialize(self.topic) handlers = {topic_str: mock_handler} listener = MyNotificationListener(handlers) - update = Update(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED) ) + update = Update(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) umsg = UMessageBuilder.notification(self.topic, self.source).build_from_upayload(UPayload.pack(update)) await listener.on_receive(umsg) mock_handler.handle_subscription_change.assert_called_once_with(update.topic, update.status) @@ -642,12 +642,11 @@ async def test_my_notification_listener_handles_handler_exception(self): topic_str = UriSerializer.serialize(self.topic) handlers = {topic_str: mock_handler} listener = MyNotificationListener(handlers) - update = Update(topic=self.topic,status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) + update = Update(topic=self.topic, status=SubscriptionStatus(state=SubscriptionStatus.State.SUBSCRIBED)) umsg = UMessageBuilder.notification(self.topic, self.source).build_from_upayload(UPayload.pack(update)) await listener.on_receive(umsg) mock_handler.handle_subscription_change.assert_called_once_with(update.topic, update.status) - async def test_register_notification_api_options_none(self): handler = MagicMock(spec=SubscriptionChangeHandler) handler.handle_subscription_change.return_value = NotImplementedError( From 24ee0da790d0a43b00fa4fd2a1b0d5faee91a2f9 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:54:43 +0200 Subject: [PATCH 24/32] added a global config --- clean_project.py | 5 ++--- config.py | 26 ++++++++++++++++++++++++++ generate_proto.py | 4 +--- 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 config.py diff --git a/clean_project.py b/clean_project.py index e5caadb..27391e9 100644 --- a/clean_project.py +++ b/clean_project.py @@ -1,5 +1,5 @@ """ -SPDX-FileCopyrightText: Copyright (c) 2023 Contributors to the +SPDX-FileCopyrightText: Copyright (c) 2025 Contributors to the Eclipse Foundation See the NOTICE file(s) distributed with this work for additional @@ -22,8 +22,7 @@ import os import shutil - -TRACK_FILE = "generated_proto_files.txt" +from config import TRACK_FILE def clean_project(): diff --git a/config.py b/config.py new file mode 100644 index 0000000..0efd675 --- /dev/null +++ b/config.py @@ -0,0 +1,26 @@ +""" +SPDX-FileCopyrightText: Copyright (c) 2025 Contributors to the +Eclipse Foundation + +See the NOTICE file(s) distributed with this work for additional +information regarding copyright ownership. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +SPDX-FileType: SOURCE +SPDX-License-Identifier: Apache-2.0 +""" + +PROTO_DIR = "up-spec/up-core-api" +OUTPUT_DIR = "." +TRACK_FILE = "generated_proto_files.txt" + diff --git a/generate_proto.py b/generate_proto.py index 93fab47..761ff5b 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -16,10 +16,8 @@ import pkg_resources from grpc_tools import protoc +from config import PROTO_DIR, OUTPUT_DIR, TRACK_FILE -PROTO_DIR = "up-spec/up-core-api" -OUTPUT_DIR = "." -TRACK_FILE = "generated_proto_files.txt" # Add google protobuf include path PROTOBUF_INCLUDE = pkg_resources.resource_filename('grpc_tools', '_proto') From 749edc6443dacfae99bbe79e4b8aa647b8b4ea4b Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 18:55:30 +0200 Subject: [PATCH 25/32] added a global config --- config.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.py b/config.py index 0efd675..40a8e51 100644 --- a/config.py +++ b/config.py @@ -20,7 +20,12 @@ SPDX-License-Identifier: Apache-2.0 """ +#Input location of protofile PROTO_DIR = "up-spec/up-core-api" + +#Output location of the proto OUTPUT_DIR = "." + +#The list of generated files TRACK_FILE = "generated_proto_files.txt" From 9497cd6be8e11ee13c3868bb3977f58b5bd36fe3 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:02:45 +0200 Subject: [PATCH 26/32] Fixes in the generation & cleanup scripts --- clean_project.py | 40 +++++++++++++++++++++++++++++++++------- generate_proto.py | 2 ++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/clean_project.py b/clean_project.py index 27391e9..cc94f3d 100644 --- a/clean_project.py +++ b/clean_project.py @@ -26,22 +26,48 @@ def clean_project(): - # Directories to remove + # ----------------------------------- + # Remove known build and cache folders + # ----------------------------------- directories_to_remove = ["build", "dist", "htmlcov", ".pytest_cache"] - - # Add *.egg-info dynamically directories_to_remove.extend([d for d in os.listdir() if d.endswith('.egg-info')]) - # Remove listed directories if they exist for directory in directories_to_remove: if os.path.exists(directory): shutil.rmtree(directory) - print(f"Removed {directory}/") + print(f"Removed directory: {directory}/") + # ----------------------------------- # Remove generated proto files + # ----------------------------------- if os.path.exists(TRACK_FILE): with open(TRACK_FILE, "r") as f: files = [line.strip() for line in f if line.strip()] + for file in files: - if os.path.exists(file): - os.remove(file) + # Normalize path to avoid './' issues + file_path = file.lstrip("./") + if os.path.exists(file_path): + os.remove(file_path) + print(f"Deleted file: {file_path}") + else: + print(f"File not found, skipping: {file_path}") + + # Remove the tracking file itself + os.remove(TRACK_FILE) + print(f"Removed tracking file: {TRACK_FILE}") + else: + print(f"No {TRACK_FILE} found, skipping proto cleanup.") + + # ----------------------------------- + # Remove all __pycache__ directories + # ----------------------------------- + for root, dirs, _ in os.walk("."): + for d in dirs: + if d == "__pycache__": + cache_path = os.path.join(root, d) + shutil.rmtree(cache_path) + print(f"Removed __pycache__: {cache_path}") + +if __name__ == "__main__": + clean_project() diff --git a/generate_proto.py b/generate_proto.py index 761ff5b..6031926 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -59,6 +59,8 @@ def generate_all_protos(): # Ensure __init__.py in all generated proto folders for root, dirs, _ in os.walk(OUTPUT_DIR): + if '.git' in root or os.path.commonpath([root, PROTO_DIR]) == PROTO_DIR: + continue init_file = os.path.join(root, '__init__.py') if not os.path.exists(init_file): open(init_file, 'a').close() From 698bc431bd1640790c598c61951f546cc833e2ec Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:04:43 +0200 Subject: [PATCH 27/32] fixes in lint --- clean_project.py | 1 + generate_proto.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/clean_project.py b/clean_project.py index cc94f3d..9230317 100644 --- a/clean_project.py +++ b/clean_project.py @@ -22,6 +22,7 @@ import os import shutil + from config import TRACK_FILE diff --git a/generate_proto.py b/generate_proto.py index 6031926..288397f 100644 --- a/generate_proto.py +++ b/generate_proto.py @@ -16,8 +16,8 @@ import pkg_resources from grpc_tools import protoc -from config import PROTO_DIR, OUTPUT_DIR, TRACK_FILE +from config import OUTPUT_DIR, PROTO_DIR, TRACK_FILE # Add google protobuf include path PROTOBUF_INCLUDE = pkg_resources.resource_filename('grpc_tools', '_proto') From 672fa937ea69f474181901869c40d625ab0a3fc9 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:06:43 +0200 Subject: [PATCH 28/32] format fixes --- clean_project.py | 1 + config.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clean_project.py b/clean_project.py index 9230317..5ca6244 100644 --- a/clean_project.py +++ b/clean_project.py @@ -70,5 +70,6 @@ def clean_project(): shutil.rmtree(cache_path) print(f"Removed __pycache__: {cache_path}") + if __name__ == "__main__": clean_project() diff --git a/config.py b/config.py index 40a8e51..a699e32 100644 --- a/config.py +++ b/config.py @@ -20,12 +20,11 @@ SPDX-License-Identifier: Apache-2.0 """ -#Input location of protofile +# Input location of protofile PROTO_DIR = "up-spec/up-core-api" -#Output location of the proto +# Output location of the proto OUTPUT_DIR = "." -#The list of generated files +# The list of generated files TRACK_FILE = "generated_proto_files.txt" - From 9f8eb1fb9320c8da112f66202a6e44ad203402b9 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:10:58 +0200 Subject: [PATCH 29/32] Reverting the original workflow --- .github/workflows/coverage.yml | 7 ++----- .github/workflows/lint.yml | 2 -- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c787db6..1873476 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,12 +1,9 @@ name: Python Test and Coverage on: - push: + pull_request: branches: - - '**' - #pull_request: - # branches: - # - main + - main jobs: test-and-coverage: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8990d46..95f971b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,8 +6,6 @@ name: Run Linter on: - push: - branches: [ "*" ] pull_request: branches: [ "main" ] From c66d49079a896e6c16c7c6d69958e8637808ff58 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:18:23 +0200 Subject: [PATCH 30/32] Removed unwanted init files in the .github --- .github/__init__.py | 0 .github/workflows/__init__.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/__init__.py delete mode 100644 .github/workflows/__init__.py diff --git a/.github/__init__.py b/.github/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/__init__.py b/.github/workflows/__init__.py deleted file mode 100644 index e69de29..0000000 From d5c0f3cc45d89fa17da0413723a896ef1e1ec16f Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:19:21 +0200 Subject: [PATCH 31/32] Reverting the lint workflow --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 95f971b..9630ab3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,7 +6,7 @@ name: Run Linter on: - pull_request: + push: branches: [ "main" ] permissions: From fff3c0ef154a92634a625ff4534cab63c08b9479 Mon Sep 17 00:00:00 2001 From: Gokul Kartha Date: Thu, 10 Jul 2025 20:20:18 +0200 Subject: [PATCH 32/32] Reverting the lint workflow --- .github/workflows/lint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9630ab3..38b4df1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,6 +8,8 @@ name: Run Linter on: push: branches: [ "main" ] + pull_request: + branches: [ "main" ] permissions: contents: read