From 1ee72a1666b32c5a350e7c75056ef45abd996dbf Mon Sep 17 00:00:00 2001 From: Jean-Paul Balabanian Date: Fri, 7 Nov 2025 11:51:15 +0100 Subject: [PATCH] add program management: add get, put, and delete by ID methods --- README.md | 3 + pyproject.toml | 2 +- tests/_programs_response.py | 39 ++++++ tests/conftest.py | 14 ++- tests/test_client.py | 8 +- tests/test_programs_by_id.py | 216 ++++++++++++++++++++++++++++++++++ toadr3/__init__.py | 10 +- toadr3/_internal/__init__.py | 3 +- toadr3/_internal/object_id.py | 11 ++ toadr3/client.py | 123 +++++++++++++++++++ toadr3/programs.py | 171 ++++++++++++++++++++++++++- toadr3/subscriptions.py | 2 +- 12 files changed, 595 insertions(+), 7 deletions(-) create mode 100644 tests/test_programs_by_id.py diff --git a/README.md b/README.md index 02fbcad..3fa61bf 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ Currently, it supports the following operations: - Update a subscription [PUT] - Delete a subscription [DELETE] - Get a subscription by id [GET] +- Update a program [PUT] +- Delete a program [DELETE] +- Get a program by id [GET] - Create a report [POST] - Create a subscription [POST] - Create a report object based on an initial event diff --git a/pyproject.toml b/pyproject.toml index d3eb94f..c84807d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "toadr3" -version = "0.27.0" +version = "0.28.0" description = "Tiny OpenADR 3 compatible client Python Library" authors = ["Jean-Paul Balabanian "] license = "Apache-2.0" diff --git a/tests/_programs_response.py b/tests/_programs_response.py index 8df5d75..9c3a6d6 100644 --- a/tests/_programs_response.py +++ b/tests/_programs_response.py @@ -42,3 +42,42 @@ async def programs_get_response(request: web.Request) -> web.Response: programs = filter_items(programs, skip, limit, x_parity) return web.json_response(data=programs, status=200) + + +async def programs_by_id_response(request: web.Request) -> web.Response: + method = request.method # if we ever need to distinguish methods + + auth = request.headers.get("Authorization", None) + credential_response = check_credentials(auth) + if credential_response is not None: + return credential_response + + program_id = request.match_info["id"] + + custom_header = request.headers.get("X-Custom-Header", None) + + # If custom header is set but not set to "CustomValue" return 400 + extra_header_response = check_custom_header(custom_header) + if extra_header_response is not None: + return extra_header_response + + subs = create_programs() + + for sub in subs: + if sub["id"] == program_id: + if method == "PUT": + program_data = await request.json() + # Update the existing program with the new data + sub.update(program_data) + sub["modificationDateTime"] = "2025-11-01T10:10:10Z" + + return web.json_response(data=sub, status=200) + + return web.json_response( + data={ + "status": 404, + "title": "Not Found", + "detail": f"Unable to find program with id: '{program_id}'", + }, + status=404, + ) diff --git a/tests/conftest.py b/tests/conftest.py index f5596e7..a38fb78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ import pytest from _common_test_utils import create_problem_response from _event_response import events_get_response -from _programs_response import programs_get_response +from _programs_response import programs_by_id_response, programs_get_response from _reports_response import reports_get_response, reports_post_response from _subscriptions_response import ( subscriptions_by_id_response, @@ -103,6 +103,18 @@ async def session(aiohttp_client: AiohttpClient) -> ClientSession: path="/vtn_url/subscriptions/{id}", handler=await _exception_wrapper(subscriptions_by_id_response), ) + app.router.add_get( + path="/vtn_url/programs/{id}", + handler=await _exception_wrapper(programs_by_id_response), + ) + app.router.add_delete( + path="/vtn_url/programs/{id}", + handler=await _exception_wrapper(programs_by_id_response), + ) + app.router.add_put( + path="/vtn_url/programs/{id}", + handler=await _exception_wrapper(programs_by_id_response), + ) app.router.add_post( path="/vtn_url/subscriptions", handler=await _exception_wrapper(subscriptions_post_response), diff --git a/tests/test_client.py b/tests/test_client.py index ee628dc..7967da1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,6 @@ import pytest from pydantic import BaseModel -from testdata import default_report_model, default_subscription_model +from testdata import default_program_model, default_report_model, default_subscription_model import toadr3 @@ -22,6 +22,9 @@ async def test_client_context_manager(client: toadr3.ToadrClient) -> None: ("method_name", "method_args"), [ ("get_programs", ()), + ("delete_program", ("id",)), + ("get_program", ("id",)), + ("put_program", ("id", default_program_model())), ("get_events", ()), ("get_reports", ()), ("post_report", (default_report_model(),)), @@ -56,6 +59,9 @@ async def test_client_default_custom_headers_passthrough( ("method_name", "method_args"), [ ("get_programs", ()), + ("delete_program", ("id",)), + ("get_program", ("id",)), + ("put_program", ("id", default_program_model())), ("get_events", ()), ("get_reports", ()), ("post_report", (default_report_model(),)), diff --git a/tests/test_programs_by_id.py b/tests/test_programs_by_id.py new file mode 100644 index 0000000..095c8a6 --- /dev/null +++ b/tests/test_programs_by_id.py @@ -0,0 +1,216 @@ +from collections.abc import Awaitable, Callable +from typing import Protocol + +import pytest +from testdata import default_program_model + +from toadr3 import ( + ToadrClient, + ToadrError, + delete_program_by_id, + get_program_by_id, + put_program_by_id, +) +from toadr3.models import Subscription + +FUNCTIONS = { + "delete_program": delete_program_by_id.__name__, + "get_program": get_program_by_id.__name__, + "put_program": put_program_by_id.__name__, +} + + +class ItemsWithID(Protocol): + """Protocol for objects with an ID attribute.""" + + id: str + + +def get_query_function(func_name: str) -> Callable[..., Awaitable[ItemsWithID | None]]: + """Get the query function based on the function name.""" + query_function_name = FUNCTIONS[func_name] + return globals()[query_function_name] # type: ignore[no-any-return] + + +@pytest.mark.parametrize( + ("func_name", "args"), + [ + ("delete_program", ("2",)), + ("get_program", ("2",)), + ("put_program", ("2", default_program_model())), + ], +) +async def test_by_id(client: ToadrClient, func_name: str, args: tuple[str, Subscription]) -> None: + result = await getattr(client, func_name)(*args) + assert result is not None + assert result.id == args[0] + + session = client.client_session + token = await client.token + vtn_url = client.vtn_url + + query_function = get_query_function(func_name) + result = await query_function(session, vtn_url, token, *args) + assert result is not None + assert result.id == args[0] + + +@pytest.mark.parametrize( + ("func_name", "args"), + [ + ("delete_program", ("3",)), + ("get_program", ("3",)), + ("put_program", ("3", default_program_model())), + ], +) +async def test_by_id_not_found( + client: ToadrClient, func_name: str, args: tuple[str, Subscription] +) -> None: + result = await getattr(client, func_name)(*args) + assert result is None + + session = client.client_session + token = await client.token + vtn_url = client.vtn_url + + msg = f"Not Found 404 - Unable to find program with id: '{args[0]}'" + query_function = get_query_function(func_name) + with pytest.raises(ToadrError, match=msg): + _ = await query_function(session, vtn_url, token, *args) + + +@pytest.mark.parametrize( + "item_id", + [ + 2, + True, + ], +) +@pytest.mark.parametrize( + "func_name", + [ + "delete_program", + "get_program", + "put_program", + ], +) +async def test_by_id_invalid_id(client: ToadrClient, func_name: str, item_id: object) -> None: + if func_name.startswith("put"): # noqa: SIM108 + args = (item_id, default_program_model()) + else: + args = (item_id,) # type: ignore[assignment] + + msg = "program_id must be a string" + with pytest.raises(ValueError, match=msg): + _ = await getattr(client, func_name)(*args) + + session = client.client_session + token = await client.token + vtn_url = client.vtn_url + + query_function = get_query_function(func_name) + with pytest.raises(ValueError, match=msg): + _ = await query_function(session, vtn_url, token, *args) + + +@pytest.mark.parametrize( + ("func_name", "args"), + [ + ("delete_program", (None,)), + ("get_program", (None,)), + ("put_program", (None, default_program_model())), + ], +) +async def test_by_id_none( + client: ToadrClient, func_name: str, args: tuple[str, Subscription] +) -> None: + arg = "program_id cannot be None" + with pytest.raises(ValueError, match=arg): + _ = await getattr(client, func_name)(*args) + + session = client.client_session + token = await client.token + vtn_url = client.vtn_url + + query_function = get_query_function(func_name) + with pytest.raises(ValueError, match=arg): + _ = await query_function(session, vtn_url, token, *args) + + +@pytest.mark.parametrize( + ("func_name", "args"), + [ + ("delete_program", ("2",)), + ("get_program", ("2",)), + ("put_program", ("2", default_program_model())), + ], +) +async def test_by_id_custom_headers( + client: ToadrClient, func_name: str, args: tuple[str, Subscription] +) -> None: + custom_headers = { + "X-Custom-Header": "CustomValue", + } + + result = await getattr(client, func_name)(*args, custom_headers=custom_headers) + assert result is not None + assert result.id == args[0] + + session = client.client_session + token = await client.token + vtn_url = client.vtn_url + + query_function = get_query_function(func_name) + result = await query_function(session, vtn_url, token, *args, custom_headers=custom_headers) + assert result is not None + assert result.id == args[0] + + +@pytest.mark.parametrize( + ("func_name", "args"), + [ + ("delete_program", ("2",)), + ("get_program", ("2",)), + ("put_program", ("2", default_program_model())), + ], +) +async def test_by_id_custom_headers_failure( + client: ToadrClient, func_name: str, args: tuple[str, Subscription] +) -> None: + custom_headers = { + "X-Custom-Header": "InvalidValue", + } + + msg = "Bad Request 400 - Invalid value for X-Custom-Header: InvalidValue" + + with pytest.raises(ToadrError, match=msg): + _ = await getattr(client, func_name)(*args, custom_headers=custom_headers) + + session = client.client_session + token = await client.token + vtn_url = client.vtn_url + + query_function = get_query_function(func_name) + with pytest.raises(ToadrError, match=msg): + _ = await query_function(session, vtn_url, token, *args, custom_headers=custom_headers) + + +async def test_by_id_put(client: ToadrClient) -> None: + program = default_program_model() + program.id = "2" + program.program_name = "Updated Program Name" + assert program.created_date_time is None + assert program.modification_date_time is None + + orig = await client.get_program("2") + assert orig is not None + assert orig.created_date_time is not None + assert orig.modification_date_time is not None + + result = await client.put_program("2", program) + assert result is not None + assert result.id == "2" + assert result.program_name == "Updated Program Name" + assert result.modification_date_time is not None + assert result.modification_date_time > orig.modification_date_time + assert result.created_date_time == orig.created_date_time diff --git a/toadr3/__init__.py b/toadr3/__init__.py index 0c605a4..9ca627e 100644 --- a/toadr3/__init__.py +++ b/toadr3/__init__.py @@ -10,7 +10,12 @@ from .client import ToadrClient from .events import get_events from .exceptions import ToadrError -from .programs import get_programs +from .programs import ( + delete_program_by_id, + get_program_by_id, + get_programs, + put_program_by_id, +) from .reports import get_reports, post_report from .subscriptions import ( delete_subscription_by_id, @@ -29,8 +34,10 @@ "ToadrError", "acquire_access_token", "acquire_access_token_from_config", + "delete_program_by_id", "delete_subscription_by_id", "get_events", + "get_program_by_id", "get_programs", "get_reports", "get_subscription_by_id", @@ -38,5 +45,6 @@ "models", "post_report", "post_subscription", + "put_program_by_id", "put_subscription_by_id", ] diff --git a/toadr3/_internal/__init__.py b/toadr3/_internal/__init__.py index 06bde79..1b85c6f 100644 --- a/toadr3/_internal/__init__.py +++ b/toadr3/_internal/__init__.py @@ -1,5 +1,5 @@ from .client_name import ClientName -from .object_id import EventID, ProgramID, SubscriptionID +from .object_id import EventID, ProgramID, ProgramIDPathParameter, SubscriptionID from .objects import Objects from .parameter_builder import ParameterBuilder from .query_handler import default_error_handler, delete_query, get_query, put_query @@ -13,6 +13,7 @@ "Objects", "ParameterBuilder", "ProgramID", + "ProgramIDPathParameter", "QueryParameter", "QueryParams", "SkipAndLimit", diff --git a/toadr3/_internal/object_id.py b/toadr3/_internal/object_id.py index c7e2f12..a372582 100644 --- a/toadr3/_internal/object_id.py +++ b/toadr3/_internal/object_id.py @@ -77,3 +77,14 @@ class SubscriptionID(ObjectID): _attribute = ("subscription_id", "subscriptionID") _nullable = False + + +class ProgramIDPathParameter(ObjectID): + """Program ID in path query parameter. + + program_id : str + The program ID to use in the path. + """ + + _attribute = ("program_id", "programID") + _nullable = False diff --git a/toadr3/client.py b/toadr3/client.py index 136b3e8..6782930 100644 --- a/toadr3/client.py +++ b/toadr3/client.py @@ -427,6 +427,129 @@ async def put_subscription( return None raise e + async def get_program( + self, program_id: str, custom_headers: dict[str, str] | None = None + ) -> Program | None: + """Get a program by ID. + + Parameters + ---------- + program_id : str + The program ID to search for. + custom_headers : dict[str, str] | None + Extra headers to include in the request. + + Returns + ------- + Program | None + The program object retrieved from the VTN or None if not found. + + Raises + ------ + ValueError + If the query parameters are invalid. + toadr3.ToadrException + If the request to the VTN fails. Specifically, response status 400, 403, or 500, + aiohttp.ClientError + If there is an unexpected error with the HTTP request to the VTN. + """ + try: + return await toadr3.get_program_by_id( + session=self._session, + vtn_url=self._vtn_url, + access_token=await self.token, + program_id=program_id, + custom_headers=self._prepare_headers(custom_headers), + ) + except ToadrError as e: + if e.status_code == NOT_FOUND: + return None + raise e + + async def delete_program( + self, program_id: str, custom_headers: dict[str, str] | None = None + ) -> Program | None: + """Delete a program by ID. + + Parameters + ---------- + program_id : str + The program ID to search for. + custom_headers : dict[str, str] | None + Extra headers to include in the request. + + Returns + ------- + Program | None + The program object retrieved from the VTN or None if not found. + + Raises + ------ + ValueError + If the query parameters are invalid. + toadr3.ToadrException + If the request to the VTN fails. Specifically, response status 400, 403, or 500, + aiohttp.ClientError + If there is an unexpected error with the HTTP request to the VTN. + """ + try: + return await toadr3.delete_program_by_id( + session=self._session, + vtn_url=self._vtn_url, + access_token=await self.token, + program_id=program_id, + custom_headers=self._prepare_headers(custom_headers), + ) + except ToadrError as e: + if e.status_code == NOT_FOUND: + return None + raise e + + async def put_program( + self, + program_id: str, + program: Program, + custom_headers: dict[str, str] | None = None, + ) -> Program | None: + """Update a program by ID. + + Parameters + ---------- + program_id : str + The program ID to search for. + program : Program + The program object with updated values. + custom_headers : dict[str, str] | None + Extra headers to include in the request. + + Returns + ------- + Program | None + The program object retrieved from the VTN or None if not found. + + Raises + ------ + ValueError + If the query parameters are invalid. + toadr3.ToadrException + If the request to the VTN fails. Specifically, response status 400, 403, or 500, + aiohttp.ClientError + If there is an unexpected error with the HTTP request to the VTN. + """ + try: + return await toadr3.put_program_by_id( + session=self._session, + vtn_url=self._vtn_url, + access_token=await self.token, + program_id=program_id, + program=program, + custom_headers=self._prepare_headers(custom_headers), + ) + except ToadrError as e: + if e.status_code == NOT_FOUND: + return None + raise e + async def get_reports( self, program_id: str | None = None, diff --git a/toadr3/programs.py b/toadr3/programs.py index cbcfff7..29d9972 100644 --- a/toadr3/programs.py +++ b/toadr3/programs.py @@ -3,9 +3,18 @@ from toadr3 import AccessToken from toadr3.models import Program, TargetType -from ._internal import ParameterBuilder, SkipAndLimit, Targets, get_query +from ._internal import ( + ParameterBuilder, + ProgramIDPathParameter, + SkipAndLimit, + Targets, + delete_query, + get_query, + put_query, +) _GET_PARAMS_BUILDER = ParameterBuilder(Targets, SkipAndLimit) +_GET_BY_ID_PARAMS_BUILDER = ParameterBuilder(ProgramIDPathParameter) async def get_programs( @@ -77,3 +86,163 @@ async def get_programs( for program in data: result.append(Program.model_validate(program)) return result + + +async def get_program_by_id( + session: aiohttp.ClientSession, + vtn_url: str, + access_token: AccessToken | None, + program_id: str, + custom_headers: dict[str, str] | None = None, +) -> Program: + """Get a program by ID. + + Parameters + ---------- + session: aiohttp.ClientSession + The aiohttp session to use for the request. + vtn_url: str + The URL of the VTN. + access_token: AccessToken | None + The access token to use for the request, use None if no token is required. + program_id : str + The program ID to search for. + custom_headers : dict[str, str] | None + Extra headers to include in the request. + + Returns + ------- + Program + The program object retrieved from the VTN. + + Raises + ------ + ValueError + If the query parameters are invalid. + toadr3.ToadrException + If the request to the VTN fails. Specifically, response status 400, 403, 404, or 500, + aiohttp.ClientError + If there is an unexpected error with the HTTP request to the VTN. + """ + _GET_BY_ID_PARAMS_BUILDER.check_query_parameters({"program_id": program_id}) + + data = await get_query( + session, + f"{vtn_url}/programs/{program_id}", + access_token, + custom_headers=custom_headers, + accept_404=True, + ) + + return Program.model_validate(data) + + +async def delete_program_by_id( + session: aiohttp.ClientSession, + vtn_url: str, + access_token: AccessToken | None, + program_id: str, + custom_headers: dict[str, str] | None = None, +) -> Program: + """Delete a subscription by ID. + + Parameters + ---------- + session: aiohttp.ClientSession + The aiohttp session to use for the request. + vtn_url: str + The URL of the VTN. + access_token: AccessToken | None + The access token to use for the request, use None if no token is required. + program_id : str + The program ID to search for. + custom_headers : dict[str, str] | None + Extra headers to include in the request. + + Returns + ------- + Program + The program object deleted from the VTN. + + Raises + ------ + ValueError + If the query parameters are invalid. + toadr3.ToadrException + If the request to the VTN fails. Specifically, response status 400, 403, 404, or 500, + aiohttp.ClientError + If there is an unexpected error with the HTTP request to the VTN. + """ + _GET_BY_ID_PARAMS_BUILDER.check_query_parameters({"program_id": program_id}) + + data = await delete_query( + session, + f"{vtn_url}/programs/{program_id}", + access_token, + custom_headers=custom_headers, + accept_404=True, + ) + + return Program.model_validate(data) + + +async def put_program_by_id( + session: aiohttp.ClientSession, + vtn_url: str, + access_token: AccessToken | None, + program_id: str, + program: Program, + custom_headers: dict[str, str] | None = None, +) -> Program: + """Update a program by ID. + + Parameters + ---------- + session: aiohttp.ClientSession + The aiohttp session to use for the request. + vtn_url: str + The URL of the VTN. + access_token: AccessToken | None + The access token to use for the request, use None if no token is required. + program_id : str + The program ID to search for. + program: Program + The program object with updated values. + custom_headers : dict[str, str] | None + Extra headers to include in the request. + + Returns + ------- + Program + The program object updated from the VTN. + + Raises + ------ + ValueError + If the query parameters are invalid. + toadr3.ToadrException + If the request to the VTN fails. Specifically, response status 400, 403, 404, or 500, + aiohttp.ClientError + If there is an unexpected error with the HTTP request to the VTN. + """ + _GET_BY_ID_PARAMS_BUILDER.check_query_parameters({"program_id": program_id}) + + if program is None: + raise ValueError("program is required") + + data = program.model_dump_json(exclude_none=True, exclude_unset=True) + + if custom_headers is None: + custom_headers = {} + custom_headers["Content-Type"] = "application/json" + + data = await put_query( + session, + f"{vtn_url}/programs/{program_id}", + access_token, + body=data, + custom_headers=custom_headers, + accept_404=True, + ) + + return Program.model_validate(data) diff --git a/toadr3/subscriptions.py b/toadr3/subscriptions.py index f8a4666..aa77317 100644 --- a/toadr3/subscriptions.py +++ b/toadr3/subscriptions.py @@ -298,7 +298,7 @@ async def put_subscription_by_id( Returns ------- Subscription - The subscription object deleted from the VTN. + The subscription object updated from the VTN. Raises ------