Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <jean-paul.balabanian@eviny.no>"]
license = "Apache-2.0"
Expand Down
39 changes: 39 additions & 0 deletions tests/_programs_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
14 changes: 13 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 7 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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(),)),
Expand Down Expand Up @@ -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(),)),
Expand Down
216 changes: 216 additions & 0 deletions tests/test_programs_by_id.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 9 additions & 1 deletion toadr3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -29,14 +34,17 @@
"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",
"get_subscriptions",
"models",
"post_report",
"post_subscription",
"put_program_by_id",
"put_subscription_by_id",
]
3 changes: 2 additions & 1 deletion toadr3/_internal/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,6 +13,7 @@
"Objects",
"ParameterBuilder",
"ProgramID",
"ProgramIDPathParameter",
"QueryParameter",
"QueryParams",
"SkipAndLimit",
Expand Down
11 changes: 11 additions & 0 deletions toadr3/_internal/object_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading