Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement Session.call method to allow calling RPC methods without any error handling to get the raw response #1092

Merged
merged 1 commit into from
Feb 8, 2024
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
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
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
44 changes: 25 additions & 19 deletions src/citric/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) -> RPCResponse:
"""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) -> RPCResponse:
"""Execute a LimeSurvey RPC with a JSON payload.

Args:
Expand Down Expand Up @@ -199,18 +210,13 @@ def _invoke(
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
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