Skip to content

Commit

Permalink
feat: Implement Session.call method to allow calling RPC methods wi…
Browse files Browse the repository at this point in the history
…thout any error handling to get the raw response
  • Loading branch information
edgarrmondragon committed Feb 8, 2024
1 parent 774e540 commit 64a7bcc
Show file tree
Hide file tree
Showing 13 changed files with 234 additions and 85 deletions.
6 changes: 6 additions & 0 deletions .changes/unreleased/Added-20240208-002559.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
kind: Added
body: Added a `Session.call` method to allow calling RPC methods without any error
handling to get the raw response
time: 2024-02-08T00:25:59.164455-06:00
custom:
Issue: "1092"
5 changes: 5 additions & 0 deletions .changes/unreleased/Documentation-20240208-002649.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Documentation
body: Linked unimplemented methods to `Session.call` examples
time: 2024-02-08T00:26:49.465352-06:00
custom:
Issue: "1092"
7 changes: 5 additions & 2 deletions code_samples/session_attr.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
"secret",
)

# Call the not_available_in_client method, not available in the Client
new_survey_id = client.session.not_available_in_client(35239, "copied_survey")
# Get the raw response from mail_registered_participants
result = client.session.call("mail_registered_participants", 35239)

# Get the raw response from remind_participants
result = client.session.call("remind_participants", 35239)
# end example
3 changes: 2 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
("py:class", "citric.types.Result"),
("py:class", "Result"),
("py:class", "YesNo"),
("py:class", "RPCResponse"),
("py:class", "T"),
("py:obj", "T"),
}
Expand Down Expand Up @@ -88,7 +89,7 @@
extlinks = {
"rpc_method": (
"https://api.limesurvey.org/classes/remotecontrol_handle.html#method_%s",
"RPC method %s",
"%s",
),
"ls_manual": (
"https://manual.limesurvey.org/%s",
Expand Down
2 changes: 1 addition & 1 deletion docs/notebooks/duckdb.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.4"
"version": "3.12.1"
}
},
"nbformat": 4,
Expand Down
116 changes: 58 additions & 58 deletions docs/rpc_coverage.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/citric/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,8 @@ def invite_participants(
) -> int:
"""Invite participants to a survey.
Calls :rpc_method:`invite_participants`.
Args:
survey_id: ID of the survey to invite participants to.
token_ids: IDs of the participants to invite.
Expand Down
48 changes: 27 additions & 21 deletions src/citric/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import sys
from types import TracebackType

from citric.types import Result, RPCResponse
from citric.types import Result, RPCResponseDict

if sys.version_info >= (3, 11):
from typing import Self # noqa: ICN003
Expand All @@ -49,12 +49,15 @@ def handle_rpc_errors(result: Result, error: str | None) -> None:
a non-null status.
LimeSurveyApiError: The response payload has a non-null error key.
"""
if isinstance(result, dict) and result.get("status") not in {"OK", None}:
raise LimeSurveyStatusError(result["status"])

if error is not None:
raise LimeSurveyApiError(error)

if not isinstance(result, dict):
return

if result.get("status") not in {"OK", None}:
raise LimeSurveyStatusError(result["status"])


class Session:
"""LimeSurvey RemoteControl 2 session.
Expand Down Expand Up @@ -134,10 +137,8 @@ def __getattr__(self, name: str) -> Method[Result]:
"""Magic method dispatcher."""
return Method(self.rpc, name)

def rpc(self, method: str, *params: t.Any) -> Result:
"""Execute RPC method on LimeSurvey, with optional token authentication.
Any method, except for `get_session_key`.
def call(self, method: str, *params: t.Any) -> RPCResponseDict:
"""Get the raw response from an RPC method.
Args:
method: Name of the method to call.
Expand All @@ -152,11 +153,21 @@ def rpc(self, method: str, *params: t.Any) -> Result:
# Methods requiring authentication
return self._invoke(method, self.key, *params)

def _invoke(
self,
method: str,
*params: t.Any,
) -> Result:
def rpc(self, method: str, *params: t.Any) -> Result:
"""Execute a LimeSurvey RPC call with error handling.
Args:
method: Name of the method to call.
params: Positional arguments of the RPC method.
Returns:
An RPC result.
"""
response = self.call(method, *params)
handle_rpc_errors(response["result"], response["error"])
return response["result"]

def _invoke(self, method: str, *params: t.Any) -> RPCResponseDict:
"""Execute a LimeSurvey RPC with a JSON payload.
Args:
Expand Down Expand Up @@ -192,25 +203,20 @@ def _invoke(
if not res.text:
raise RPCInterfaceNotEnabledError

data: RPCResponse
data: RPCResponseDict

try:
data = res.json()
except json.JSONDecodeError as e:
raise InvalidJSONResponseError from e

result = data["result"]
error = data["error"]
response_id = data["id"]
logger.info("Invoked RPC method %s with ID %d", method, request_id)

handle_rpc_errors(result, error)

if response_id != request_id:
if (response_id := data["id"]) != request_id:
msg = f"Response ID {response_id} does not match request ID {request_id}"
raise ResponseMismatchError(msg)

return result
return data

def close(self) -> None:
"""Close RPC session.
Expand Down
4 changes: 2 additions & 2 deletions src/citric/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"QuestionsListElement",
"QuotaListElement",
"QuotaProperties",
"RPCResponse",
"RPCResponseDict",
"Result",
"SetQuotaPropertiesResult",
"SurveyProperties",
Expand Down Expand Up @@ -358,7 +358,7 @@ class QuotaProperties(t.TypedDict, total=False):
"""Whether the quota autoload URL is active."""


class RPCResponse(t.TypedDict):
class RPCResponseDict(t.TypedDict):
"""RPC response payload."""

id: int
Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ def pytest_addoption(parser: pytest.Parser):
default=_from_env_var("LS_PASSWORD"),
)

parser.addoption(
"--mailhog-url",
action="store",
help="URL of the MailHog instance to test against.",
default=_from_env_var("MAILHOG_URL", "http://localhost:8025"),
)


def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]):
"""Modify test collection."""
Expand Down Expand Up @@ -109,6 +116,12 @@ def integration_password(request: pytest.FixtureRequest) -> str:
return request.config.getoption("--limesurvey-password")


@pytest.fixture(scope="session")
def integration_mailhog_url(request: pytest.FixtureRequest) -> str:
"""MailHog URL."""
return request.config.getoption("--mailhog-url")


class LimeSurveyMockAdapter(BaseAdapter):
"""Requests adapter that mocks LSRC2 API calls."""

Expand Down
20 changes: 20 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""MailHog API client."""

from __future__ import annotations

import requests


class MailHogClient:
"""MailHog API client."""

def __init__(self, base_url: str) -> None:
self.base_url = base_url

def get_all(self) -> dict:
"""Get all messages."""
return requests.get(f"{self.base_url}/api/v2/messages", timeout=10).json()

def delete(self) -> None:
"""Delete all messages."""
requests.delete(f"{self.base_url}/api/v1/messages", timeout=10)
9 changes: 9 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import citric
from citric.exceptions import LimeSurveyStatusError
from tests.fixtures import MailHogClient


@pytest.fixture(scope="session")
Expand Down Expand Up @@ -53,3 +54,11 @@ def server_version(client: citric.Client) -> semver.Version:
def database_version(client: citric.Client) -> int:
"""Get the LimeSurvey database schema version."""
return client.get_db_version()


@pytest.fixture
def mailhog(integration_mailhog_url: str) -> MailHogClient:
"""Get the LimeSurvey database schema version."""
client = MailHogClient(integration_mailhog_url)
client.delete()
return client
84 changes: 84 additions & 0 deletions tests/integration/test_rpc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from faker import Faker
from pytest_subtests import SubTests

from tests.fixtures import MailHogClient

NEW_SURVEY_NAME = "New Survey"


Expand Down Expand Up @@ -752,3 +754,85 @@ def test_users(client: citric.Client):
def test_survey_groups(client: citric.Client):
"""Test survey group methods."""
assert len(client.list_survey_groups()) == 1


@pytest.mark.integration_test
def test_mail_registered_participants(
client: citric.Client,
survey_id: int,
participants: list[dict[str, str]],
mailhog: MailHogClient,
subtests: SubTests,
):
"""Test mail_registered_participants."""
client.activate_survey(survey_id)
client.activate_tokens(survey_id, [1, 2])
client.add_participants(
survey_id,
participant_data=participants,
create_tokens=False,
)

with subtests.test(msg="No initial emails"):
assert mailhog.get_all()["total"] == 0

# `mail_registered_participants` returns a non-error status messages even when
# emails are sent successfully and that violates assumptions made by this
# library about the meaning of `status` messages
with pytest.raises(
LimeSurveyStatusError,
match="0 left to send",
):
client.session.mail_registered_participants(survey_id)

with subtests.test(msg="2 emails sent"):
assert mailhog.get_all()["total"] == 2

mailhog.delete()

with pytest.raises(
LimeSurveyStatusError,
match="Error: No candidate tokens",
):
client.session.mail_registered_participants(survey_id)

with subtests.test(msg="No more emails sent"):
assert mailhog.get_all()["total"] == 0


@pytest.mark.integration_test
def test_remind_participants(
client: citric.Client,
survey_id: int,
participants: list[dict[str, str]],
mailhog: MailHogClient,
subtests: SubTests,
):
"""Test remind_participants."""
client.activate_survey(survey_id)
client.activate_tokens(survey_id, [1, 2])
client.add_participants(
survey_id,
participant_data=participants,
create_tokens=False,
)

with subtests.test(msg="No initial emails"):
assert mailhog.get_all()["total"] == 0

# Use `call` to avoid error handling
client.session.call("mail_registered_participants", survey_id)

with subtests.test(msg="2 emails sent"):
assert mailhog.get_all()["total"] == 2

mailhog.delete()

# `remind_participants` returns a non-error status messages even when emails are
# sent successfully and that violates assumptions made by this library about the
# meaning of `status` messages"
with pytest.raises(LimeSurveyStatusError, match="0 left to send"):
client.session.remind_participants(survey_id)

with subtests.test(msg="2 reminders sent"):
assert mailhog.get_all()["total"] == 2

0 comments on commit 64a7bcc

Please sign in to comment.