From 90be6681b9944ac406aca34a0d63340c825f5d3f Mon Sep 17 00:00:00 2001 From: lleeoo Date: Wed, 24 Jan 2024 19:52:17 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F(project)=20migrate=20to?= =?UTF-8?q?=20`pydantic`=20v2=20and=20switch=20tests=20to=20`polyfactory`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrating to `pydantic` v2 should speed up processing and allow interoperability with projects such as `warren`. This migration makes the hypothesis package used in tests obsolete, which is why we introduce `polyfactory`. --- docs/CHANGELOG.md | 1 - docs/LICENSE.md | 1 - docs/commands.md | 6 - pyproject.toml | 5 +- src/ralph/api/__init__.py | 5 +- src/ralph/api/auth/basic.py | 32 +- src/ralph/api/auth/oidc.py | 12 +- src/ralph/api/auth/user.py | 20 +- src/ralph/api/forwarding.py | 6 +- src/ralph/api/models.py | 12 +- src/ralph/api/routers/health.py | 2 +- src/ralph/api/routers/statements.py | 37 +- src/ralph/backends/data/async_es.py | 4 +- src/ralph/backends/data/async_lrs.py | 14 +- src/ralph/backends/data/async_mongo.py | 5 +- src/ralph/backends/data/async_ws.py | 13 +- src/ralph/backends/data/base.py | 26 +- src/ralph/backends/data/clickhouse.py | 23 +- src/ralph/backends/data/es.py | 27 +- src/ralph/backends/data/fs.py | 25 +- src/ralph/backends/data/ldp.py | 11 +- src/ralph/backends/data/lrs.py | 36 +- src/ralph/backends/data/mongo.py | 38 +- src/ralph/backends/data/s3.py | 15 +- src/ralph/backends/data/swift.py | 11 +- src/ralph/backends/lrs/base.py | 61 +-- src/ralph/backends/lrs/clickhouse.py | 21 +- src/ralph/backends/lrs/es.py | 18 +- src/ralph/backends/lrs/fs.py | 16 +- src/ralph/backends/lrs/mongo.py | 15 +- src/ralph/cli.py | 36 +- src/ralph/conf.py | 161 ++++--- src/ralph/models/converter.py | 5 +- src/ralph/models/edx/base.py | 47 +- src/ralph/models/edx/browser.py | 7 +- .../models/edx/converters/xapi/enrollment.py | 5 +- .../models/edx/converters/xapi/server.py | 2 +- src/ralph/models/edx/converters/xapi/video.py | 2 +- .../models/edx/enrollment/fields/contexts.py | 3 +- .../models/edx/enrollment/fields/events.py | 2 +- src/ralph/models/edx/enrollment/statements.py | 3 +- .../models/edx/navigational/fields/events.py | 18 +- .../models/edx/navigational/statements.py | 6 +- .../open_response_assessment/fields/events.py | 41 +- .../edx/peer_instruction/fields/events.py | 5 +- .../edx/problem_interaction/fields/events.py | 158 ++++--- .../edx/textbook_interaction/fields/events.py | 22 +- src/ralph/models/edx/video/fields/events.py | 21 +- src/ralph/models/edx/video/statements.py | 14 +- src/ralph/models/validator.py | 5 +- src/ralph/models/xapi/base/agents.py | 10 +- src/ralph/models/xapi/base/attachments.py | 4 +- src/ralph/models/xapi/base/common.py | 70 +-- src/ralph/models/xapi/base/contexts.py | 28 +- src/ralph/models/xapi/base/groups.py | 6 +- src/ralph/models/xapi/base/ifi.py | 13 +- src/ralph/models/xapi/base/objects.py | 8 +- src/ralph/models/xapi/base/results.py | 47 +- src/ralph/models/xapi/base/statements.py | 21 +- .../models/xapi/base/unnested_objects.py | 51 ++- src/ralph/models/xapi/base/verbs.py | 2 +- .../xapi/concepts/verbs/acrossx_profile.py | 2 +- .../verbs/activity_streams_vocabulary.py | 4 +- .../xapi/concepts/verbs/adl_vocabulary.py | 6 +- .../verbs/navy_common_reference_profile.py | 4 +- .../xapi/concepts/verbs/scorm_profile.py | 8 +- .../xapi/concepts/verbs/tincan_vocabulary.py | 6 +- src/ralph/models/xapi/concepts/verbs/video.py | 6 +- .../xapi/concepts/verbs/virtual_classroom.py | 24 +- src/ralph/models/xapi/config.py | 14 +- src/ralph/models/xapi/lms/contexts.py | 40 +- src/ralph/models/xapi/lms/objects.py | 14 +- src/ralph/models/xapi/video/contexts.py | 67 +-- src/ralph/models/xapi/video/results.py | 25 +- .../models/xapi/virtual_classroom/contexts.py | 25 +- .../models/xapi/virtual_classroom/results.py | 4 +- .../xapi/virtual_classroom/statements.py | 1 - tests/api/auth/test_basic.py | 6 +- tests/api/test_forwarding.py | 85 ++-- tests/api/test_statements_get.py | 14 +- tests/backends/data/test_async_lrs.py | 5 +- tests/backends/data/test_async_mongo.py | 4 +- tests/backends/data/test_async_ws.py | 19 +- tests/backends/data/test_base.py | 25 +- tests/backends/data/test_fs.py | 7 +- tests/backends/data/test_mongo.py | 4 +- tests/backends/lrs/test_async_es.py | 14 +- tests/backends/lrs/test_async_mongo.py | 12 +- tests/backends/lrs/test_clickhouse.py | 23 +- tests/backends/lrs/test_es.py | 12 +- tests/backends/lrs/test_fs.py | 2 +- tests/backends/lrs/test_mongo.py | 14 +- tests/conftest.py | 4 - tests/factories.py | 219 ++++++++++ tests/fixtures/backends.py | 6 +- tests/fixtures/hypothesis_configuration.py | 22 - tests/fixtures/hypothesis_strategies.py | 127 ------ tests/helpers.py | 12 +- tests/models/edx/converters/xapi/test_base.py | 2 +- .../edx/converters/xapi/test_enrollment.py | 35 +- .../edx/converters/xapi/test_navigational.py | 15 +- .../models/edx/converters/xapi/test_server.py | 35 +- .../models/edx/converters/xapi/test_video.py | 87 ++-- tests/models/edx/navigational/test_events.py | 17 +- .../edx/navigational/test_statements.py | 56 +-- .../open_response_assessment/test_events.py | 34 +- .../test_statements.py | 51 +-- .../edx/peer_instruction/test_events.py | 17 +- .../edx/peer_instruction/test_statements.py | 26 +- .../edx/problem_interaction/test_events.py | 227 +++++----- .../problem_interaction/test_statements.py | 76 ++-- tests/models/edx/test_base.py | 39 +- tests/models/edx/test_browser.py | 22 +- tests/models/edx/test_enrollment.py | 38 +- tests/models/edx/test_server.py | 9 +- .../edx/textbook_interaction/test_events.py | 32 +- .../textbook_interaction/test_statements.py | 82 ++-- tests/models/edx/video/test_events.py | 30 +- tests/models/edx/video/test_statements.py | 68 +-- tests/models/test_converter.py | 41 +- tests/models/test_validator.py | 43 +- tests/models/xapi/base/test_agents.py | 19 +- tests/models/xapi/base/test_common.py | 54 ++- tests/models/xapi/base/test_groups.py | 8 +- tests/models/xapi/base/test_objects.py | 6 +- tests/models/xapi/base/test_results.py | 8 +- tests/models/xapi/base/test_statements.py | 400 ++++++++++-------- .../models/xapi/base/test_unnested_objects.py | 28 +- .../xapi/concepts/test_activity_types.py | 12 +- tests/models/xapi/concepts/test_verbs.py | 10 +- tests/models/xapi/test_lms.py | 110 ++--- tests/models/xapi/test_navigation.py | 18 +- tests/models/xapi/test_video.py | 106 ++--- tests/models/xapi/test_virtual_classroom.py | 128 +++--- tests/test_cli.py | 48 ++- tests/test_cli_usage.py | 43 +- tests/test_conf.py | 14 +- tests/test_utils.py | 2 +- 138 files changed, 2315 insertions(+), 2008 deletions(-) delete mode 120000 docs/CHANGELOG.md delete mode 120000 docs/LICENSE.md delete mode 100644 docs/commands.md create mode 100644 tests/factories.py delete mode 100644 tests/fixtures/hypothesis_configuration.py delete mode 100644 tests/fixtures/hypothesis_strategies.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md deleted file mode 120000 index 04c99a55c..000000000 --- a/docs/CHANGELOG.md +++ /dev/null @@ -1 +0,0 @@ -../CHANGELOG.md \ No newline at end of file diff --git a/docs/LICENSE.md b/docs/LICENSE.md deleted file mode 120000 index 7eabdb1c2..000000000 --- a/docs/LICENSE.md +++ /dev/null @@ -1 +0,0 @@ -../LICENSE.md \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md deleted file mode 100644 index 12616600f..000000000 --- a/docs/commands.md +++ /dev/null @@ -1,6 +0,0 @@ -# Commands - -::: mkdocs-click - :module: ralph.cli - :command: cli - :depth: 1 diff --git a/pyproject.toml b/pyproject.toml index 473820f38..4eff40c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ # By default, we only consider core dependencies required to use Ralph as a # library (mostly models). "langcodes>=3.2.0", - "pydantic[dotenv,email]>=1.10.0, <2.0", + "pydantic[email]>=2.5.3,<3.0", + "pydantic_settings>=2.1.0,<3.0", "rfc3987>=1.3.0", ] dynamic = ["version"] @@ -91,7 +92,6 @@ dev = [ "black==23.12.1", "cryptography==41.0.7", "factory-boy==3.3.0", - "hypothesis<6.92.0", # pin as hypothesis 6.92.0 observability feature seems broken "logging-gelf==0.0.31", "mike==2.0.0", "mkdocs==1.5.3", @@ -103,6 +103,7 @@ dev = [ "neoteroi-mkdocs==1.0.4", "pyfakefs==5.3.2", "pymdown-extensions==10.7", + "polyfactory==2.14.1", "pytest==7.4.4", "pytest-asyncio==0.23.3", "pytest-cov==4.1.0", diff --git a/src/ralph/api/__init__.py b/src/ralph/api/__init__.py index 3f6945799..a7d0cd5a8 100644 --- a/src/ralph/api/__init__.py +++ b/src/ralph/api/__init__.py @@ -50,4 +50,7 @@ async def whoami( user: AuthenticatedUser = Depends(get_authenticated_user), ) -> Dict[str, Any]: """Return the current user's username along with their scopes.""" - return {"agent": user.agent, "scopes": user.scopes} + return { + "agent": user.agent.model_dump(mode="json", exclude_none=True), + "scopes": user.scopes, + } diff --git a/src/ralph/api/auth/basic.py b/src/ralph/api/auth/basic.py index 759e83054..cee9986ee 100644 --- a/src/ralph/api/auth/basic.py +++ b/src/ralph/api/auth/basic.py @@ -1,6 +1,7 @@ """Basic authentication & authorization related tools for the Ralph API.""" import logging +import os from functools import lru_cache from pathlib import Path from threading import Lock @@ -10,7 +11,7 @@ from cachetools import TTLCache, cached from fastapi import Depends, HTTPException, status from fastapi.security import HTTPBasic, HTTPBasicCredentials -from pydantic import BaseModel, root_validator +from pydantic import RootModel, model_validator from starlette.authentication import AuthenticationError from ralph.api.auth.user import AuthenticatedUser @@ -40,45 +41,42 @@ class UserCredentials(AuthenticatedUser): username: str -class ServerUsersCredentials(BaseModel): +class ServerUsersCredentials(RootModel[List[UserCredentials]]): """Custom root pydantic model. Describe expected list of all server users credentials as stored in the credentials file. Attributes: - __root__ (List): Custom root consisting of the + root (List): Custom root consisting of the list of all server users credentials. """ - __root__: List[UserCredentials] - def __add__(self, other) -> Any: # noqa: D105 - return ServerUsersCredentials.parse_obj(self.__root__ + other.__root__) + return ServerUsersCredentials.model_validate(self.root + other.root) def __getitem__(self, item: int) -> UserCredentials: # noqa: D105 - return self.__root__[item] + return self.root[item] def __len__(self) -> int: # noqa: D105 - return len(self.__root__) + return len(self.root) def __iter__(self) -> Iterator[UserCredentials]: # noqa: D105 - return iter(self.__root__) + return iter(self.root) - @root_validator - @classmethod - def ensure_unique_username(cls, values: Any) -> Any: + @model_validator(mode="after") + def ensure_unique_username(self) -> Any: """Every username should be unique among registered users.""" - usernames = [entry.username for entry in values.get("__root__")] + usernames = [entry.username for entry in self.root] if len(usernames) != len(set(usernames)): raise ValueError( "You cannot create multiple credentials with the same username" ) - return values + return self @lru_cache() -def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials: +def get_stored_credentials(auth_file: os.PathLike) -> ServerUsersCredentials: """Helper to read the credentials/scopes file. Read credentials from JSON file and stored them to avoid reloading them with every @@ -96,7 +94,9 @@ def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials: msg = "Credentials file <%s> not found." logger.warning(msg, auth_file) raise AuthenticationError(msg.format(auth_file)) - return ServerUsersCredentials.parse_file(auth_file) + + with open(auth_file, encoding=settings.LOCALE_ENCODING) as f: + return ServerUsersCredentials.model_validate_json(f.read()) @cached( diff --git a/src/ralph/api/auth/oidc.py b/src/ralph/api/auth/oidc.py index 1edb6b420..6a7376fd3 100644 --- a/src/ralph/api/auth/oidc.py +++ b/src/ralph/api/auth/oidc.py @@ -9,7 +9,7 @@ from fastapi.security import HTTPBearer, OpenIdConnect from jose import ExpiredSignatureError, JWTError, jwt from jose.exceptions import JWTClaimsError -from pydantic import AnyUrl, BaseModel, Extra +from pydantic import AnyUrl, BaseModel, ConfigDict from typing_extensions import Annotated from ralph.api.auth.user import AuthenticatedUser, UserScopes @@ -44,13 +44,11 @@ class IDToken(BaseModel): iss: str sub: str - aud: Optional[str] + aud: Optional[str] = None exp: int iat: int - scope: Optional[str] - - class Config: # noqa: D106 - extra = Extra.ignore + scope: Optional[str] = None + model_config = ConfigDict(extra="ignore") @lru_cache() @@ -142,7 +140,7 @@ def get_oidc_user( headers={"WWW-Authenticate": "Bearer"}, ) from exc - id_token = IDToken.parse_obj(decoded_token) + id_token = IDToken.model_validate(decoded_token) user = AuthenticatedUser( agent={"openid": f"{id_token.iss}/{id_token.sub}"}, diff --git a/src/ralph/api/auth/user.py b/src/ralph/api/auth/user.py index ec2d6837c..8e296c5ab 100644 --- a/src/ralph/api/auth/user.py +++ b/src/ralph/api/auth/user.py @@ -1,8 +1,10 @@ """Authenticated user for the Ralph API.""" -from typing import Dict, FrozenSet, Literal +from typing import FrozenSet, Literal -from pydantic import BaseModel +from pydantic import BaseModel, RootModel + +from ralph.models.xapi.base.agents import BaseXapiAgent Scope = Literal[ "statements/write", @@ -18,7 +20,7 @@ ] -class UserScopes(FrozenSet[Scope]): +class UserScopes(RootModel[FrozenSet[Scope]]): """Scopes available to users.""" def is_authorized(self, requested_scope: Scope): @@ -47,19 +49,11 @@ def is_authorized(self, requested_scope: Scope): } expanded_user_scopes = set() - for scope in self: + for scope in self.root: expanded_user_scopes.update(expanded_scopes.get(scope, {scope})) return requested_scope in expanded_user_scopes - @classmethod - def __get_validators__(cls): # noqa: D105 - def validate(value: FrozenSet[Scope]): - """Transform value to an instance of UserScopes.""" - return cls(value) - - yield validate - class AuthenticatedUser(BaseModel): """Pydantic model for user authentication. @@ -69,5 +63,5 @@ class AuthenticatedUser(BaseModel): scopes (list): The scopes the user has access to. """ - agent: Dict + agent: BaseXapiAgent scopes: UserScopes diff --git a/src/ralph/api/forwarding.py b/src/ralph/api/forwarding.py index 6c85cc8b6..7da68d43d 100644 --- a/src/ralph/api/forwarding.py +++ b/src/ralph/api/forwarding.py @@ -42,7 +42,7 @@ async def forward_xapi_statements( try: # NB: post or put req = await getattr(client, method)( - forwarding.url, + str(forwarding.url), json=statements, auth=(forwarding.basic_username, forwarding.basic_password), timeout=forwarding.timeout, @@ -50,8 +50,8 @@ async def forward_xapi_statements( req.raise_for_status() msg = "Forwarded %s statements to %s with success." if isinstance(statements, list): - logger.debug(msg, len(statements), forwarding.url) + logger.debug(msg, len(statements), str(forwarding.url)) else: - logger.debug(msg, 1, forwarding.url) + logger.debug(msg, 1, str(forwarding.url)) except (RequestError, HTTPStatusError) as error: logger.error("Failed to forward xAPI statements. %s", error) diff --git a/src/ralph/api/models.py b/src/ralph/api/models.py index 94a8ee3d1..987a93274 100644 --- a/src/ralph/api/models.py +++ b/src/ralph/api/models.py @@ -6,7 +6,7 @@ from typing import Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, Extra +from pydantic import AnyUrl, BaseModel, ConfigDict from ..models.xapi.base.agents import BaseXapiAgent from ..models.xapi.base.groups import BaseXapiGroup @@ -29,13 +29,7 @@ class BaseModelWithLaxConfig(BaseModel): we receive statements through the API. """ - class Config: - """Enable extra properties. - - Useful for not having to perform comprehensive validation. - """ - - extra = Extra.allow + model_config = ConfigDict(extra="allow", coerce_numbers_to_str=True) class LaxObjectField(BaseModelWithLaxConfig): @@ -64,6 +58,6 @@ class LaxStatement(BaseModelWithLaxConfig): """ actor: Union[BaseXapiAgent, BaseXapiGroup] - id: Optional[UUID] + id: Optional[UUID] = None object: LaxObjectField verb: LaxVerbField diff --git a/src/ralph/api/routers/health.py b/src/ralph/api/routers/health.py index 536b0c263..b57d704a3 100644 --- a/src/ralph/api/routers/health.py +++ b/src/ralph/api/routers/health.py @@ -47,7 +47,7 @@ async def heartbeat(response: Response) -> Heartbeat: Return a 200 if all checks are successful. """ - statuses = Heartbeat.construct( + statuses = Heartbeat.model_construct( database=await await_if_coroutine(BACKEND_CLIENT.status()) ) if not statuses.is_alive: diff --git a/src/ralph/api/routers/statements.py b/src/ralph/api/routers/statements.py index 7c1e79d85..0fa373ca0 100644 --- a/src/ralph/api/routers/statements.py +++ b/src/ralph/api/routers/statements.py @@ -10,6 +10,7 @@ from fastapi import ( APIRouter, BackgroundTasks, + Body, Depends, HTTPException, Query, @@ -19,7 +20,7 @@ status, ) from fastapi.dependencies.models import Dependant -from pydantic import parse_obj_as +from pydantic import TypeAdapter from pydantic.types import Json from typing_extensions import Annotated @@ -98,14 +99,17 @@ def _enrich_statement_with_authority( ) -> None: # authority: Information about whom or what has asserted the statement is true. # https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#249-authority - statement["authority"] = current_user.agent + statement["authority"] = current_user.agent.model_dump( + exclude_none=True, mode="json" + ) def _parse_agent_parameters(agent_obj: dict) -> AgentParameters: """Parse a dict and return an AgentParameters object to use in queries.""" # Transform agent to `dict` as FastAPI cannot parse JSON (seen as string) - agent = parse_obj_as(BaseXapiAgent, agent_obj) + adapter = TypeAdapter(BaseXapiAgent) + agent = adapter.validate_python(agent_obj) agent_query_params = {} if isinstance(agent, BaseXapiAgentWithMbox): @@ -119,7 +123,7 @@ def _parse_agent_parameters(agent_obj: dict) -> AgentParameters: agent_query_params["account__home_page"] = agent.account.homePage # Overwrite `agent` field - return AgentParameters.construct(**agent_query_params) + return AgentParameters.model_construct(**agent_query_params) def strict_query_params(request: Request) -> None: @@ -141,7 +145,7 @@ def strict_query_params(request: Request) -> None: @router.get("") @router.get("/") -async def get( # noqa: PLR0913 +async def get( # noqa: PLR0912,PLR0913 request: Request, current_user: Annotated[ AuthenticatedUser, @@ -169,7 +173,7 @@ async def get( # noqa: PLR0913 None, description="Filter, only return Statements matching the specified Verb id", ), - activity: Optional[IRI] = Query( + activity: Optional[Annotated[IRI, Body()]] = Query( None, description=( "Filter, only return Statements for which the Object " @@ -334,7 +338,14 @@ async def get( # noqa: PLR0913 # Overwrite `agent` field query_params["agent"] = _parse_agent_parameters( json.loads(query_params["agent"]) - ) + ).model_dump(mode="json", exclude_none=True) + + # Coerce `verb` and `activity` as IRI + if query_params.get("verb"): + query_params["verb"] = IRI(query_params["verb"]) + + if query_params.get("activity"): + query_params["activity"] = IRI(query_params["activity"]) # mine: If using scopes, only restrict users with limited scopes if settings.LRS_RESTRICT_BY_SCOPES: @@ -346,7 +357,9 @@ async def get( # noqa: PLR0913 # Filter by authority if using `mine` if mine: - query_params["authority"] = _parse_agent_parameters(current_user.agent) + query_params["authority"] = _parse_agent_parameters( + current_user.agent.model_dump(mode="json") + ).model_dump(mode="json", exclude_none=True) if "mine" in query_params: query_params.pop("mine") @@ -355,7 +368,7 @@ async def get( # noqa: PLR0913 try: query_result = await await_if_coroutine( BACKEND_CLIENT.query_statements( - RalphStatementsQuery.construct(**{**query_params, "limit": limit}) + RalphStatementsQuery.model_construct(**{**query_params, "limit": limit}) ) ) except BackendException as error: @@ -415,7 +428,7 @@ async def put( LRS Specification: https://github.com/adlnet/xAPI-Spec/blob/1.0.3/xAPI-Communication.md#211-put-statements """ - statement_as_dict = statement.dict(exclude_unset=True) + statement_as_dict = statement.model_dump(exclude_unset=True, mode="json") statement_id = str(statement_id) statement_as_dict.update(id=str(statement_as_dict.get("id", statement_id))) @@ -504,7 +517,9 @@ async def post( # noqa: PLR0912 # Enrich statements before forwarding statements_dict = {} - for statement in (x.dict(exclude_unset=True) for x in statements): + for statement in ( + x.model_dump(exclude_unset=True, mode="json") for x in statements + ): _enrich_statement_with_id(statement) # Requests with duplicate statement IDs are considered invalid if statement["id"] in statements_dict: diff --git a/src/ralph/backends/data/async_es.py b/src/ralph/backends/data/async_es.py index cfd2e1293..296313219 100644 --- a/src/ralph/backends/data/async_es.py +++ b/src/ralph/backends/data/async_es.py @@ -45,7 +45,7 @@ def client(self) -> AsyncElasticsearch: """Create an AsyncElasticsearch client if it doesn't exist.""" if not self._client: self._client = AsyncElasticsearch( - self.settings.HOSTS, **self.settings.CLIENT_OPTIONS.dict() + self.settings.HOSTS, **self.settings.CLIENT_OPTIONS.model_dump() ) return self._client @@ -179,7 +179,7 @@ async def _read_dicts( raise BackendException(msg % error) from error limit = query.size - kwargs = query.dict() + kwargs = query.model_dump() count = chunk_size # The first condition is set to comprise either limit as None # (when the backend query does not have `size` parameter), diff --git a/src/ralph/backends/data/async_lrs.py b/src/ralph/backends/data/async_lrs.py index 724dbde71..fdddbb29c 100644 --- a/src/ralph/backends/data/async_lrs.py +++ b/src/ralph/backends/data/async_lrs.py @@ -52,13 +52,13 @@ def __init__(self, settings: Optional[LRSDataBackendSettings] = None) -> None: def client(self) -> AsyncClient: """Create a `httpx.AsyncClient` if it doesn't exist.""" if not self._client: - headers = self.settings.HEADERS.dict(by_alias=True) + headers = self.settings.HEADERS.model_dump(by_alias=True) self._client = AsyncClient(auth=self.auth, headers=headers) return self._client async def status(self) -> DataBackendStatus: """HTTP backend check for server status.""" - status_url = urljoin(self.base_url, self.settings.STATUS_ENDPOINT) + status_url = urljoin(str(self.base_url), self.settings.STATUS_ENDPOINT) try: response = await self.client.get(status_url) response.raise_for_status() @@ -138,8 +138,8 @@ async def _read_dicts( # Create request URL target = ParseResult( - scheme=urlparse(self.base_url).scheme, - netloc=urlparse(self.base_url).netloc, + scheme=self.base_url.scheme, + netloc=urlparse(str(self.base_url)).netloc, path=target, query="", params="", @@ -148,7 +148,7 @@ async def _read_dicts( statements = self._fetch_statements( target=target, - query_params=query.dict(exclude_none=True, exclude_unset=True), + query_params=query.model_dump(exclude_none=True, exclude_unset=True), ) # Iterate through results @@ -204,8 +204,8 @@ async def _write_dicts( # noqa: PLR0913 target = self.settings.STATEMENTS_ENDPOINT target = ParseResult( - scheme=urlparse(self.base_url).scheme, - netloc=urlparse(self.base_url).netloc, + scheme=self.base_url.scheme, + netloc=urlparse(str(self.base_url)).netloc, path=target, query="", params="", diff --git a/src/ralph/backends/data/async_mongo.py b/src/ralph/backends/data/async_mongo.py index e162c298d..e326d9355 100644 --- a/src/ralph/backends/data/async_mongo.py +++ b/src/ralph/backends/data/async_mongo.py @@ -46,8 +46,9 @@ def __init__(self, settings: Optional[Settings] = None): settings (MongoDataBackendSettings or None): The data backend settings. """ super().__init__(settings) + host = str(self.settings.CONNECTION_URI) self.client = AsyncIOMotorClient( - self.settings.CONNECTION_URI, **self.settings.CLIENT_OPTIONS.dict() + host, **self.settings.CLIENT_OPTIONS.model_dump() ) self.database = self.client[self.settings.DEFAULT_DATABASE] self.collection = self.database[self.settings.DEFAULT_COLLECTION] @@ -175,7 +176,7 @@ async def _read_dicts( ignore_errors: bool, # noqa: ARG002 ) -> AsyncIterator[dict]: """Method called by `self.read` yielding dictionaries. See `self.read`.""" - kwargs = query.dict(exclude_unset=True) + kwargs = query.model_dump(exclude_unset=True) collection = self._get_target_collection(target) try: async for document in collection.find(batch_size=chunk_size, **kwargs): diff --git a/src/ralph/backends/data/async_ws.py b/src/ralph/backends/data/async_ws.py index 074f8c7f3..01299a099 100644 --- a/src/ralph/backends/data/async_ws.py +++ b/src/ralph/backends/data/async_ws.py @@ -5,6 +5,7 @@ import websockets from pydantic import AnyUrl, PositiveInt +from pydantic_settings import SettingsConfigDict from websockets.http import USER_AGENT from ralph.backends.data.base import ( @@ -12,7 +13,7 @@ BaseDataBackendSettings, DataBackendStatus, ) -from ralph.conf import BaseSettingsConfig, ClientOptions +from ralph.conf import BASE_SETTINGS_CONFIG, ClientOptions from ralph.exceptions import BackendException logger = logging.getLogger(__name__) @@ -68,10 +69,10 @@ class WSDataBackendSettings(BaseDataBackendSettings): URI (str): The URI to connect to. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__WS__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__WS__"), + } CLIENT_OPTIONS: WSClientOptions = WSClientOptions() URI: AnyUrl @@ -97,7 +98,7 @@ async def client(self) -> websockets.WebSocketClientProtocol: if not self._client: try: self._client = await websockets.connect( - self.settings.URI, **self.settings.CLIENT_OPTIONS.dict() + str(self.settings.URI), **self.settings.CLIENT_OPTIONS.model_dump() ) except (websockets.WebSocketException, OSError, TimeoutError) as error: msg = "Failed open websocket connection for %s: %s" diff --git a/src/ralph/backends/data/base.py b/src/ralph/backends/data/base.py index 51e7a1bc6..7b67e8a41 100644 --- a/src/ralph/backends/data/base.py +++ b/src/ralph/backends/data/base.py @@ -22,10 +22,11 @@ get_origin, ) -from pydantic import BaseModel, BaseSettings, PositiveInt, ValidationError +from pydantic import BaseModel, PositiveInt, ValidationError +from pydantic_settings import BaseSettings, SettingsConfigDict from typing_extensions import Self, get_original_bases -from ralph.conf import BaseSettingsConfig, core_settings +from ralph.conf import BASE_SETTINGS_CONFIG, core_settings from ralph.exceptions import BackendParameterException from ralph.utils import ( async_parse_dict_to_bytes, @@ -42,12 +43,14 @@ class BaseDataBackendSettings(BaseSettings): """Data backend default configuration.""" - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__" - env_file = ".env" - env_file_encoding = core_settings.LOCALE_ENCODING + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict( + env_prefix="RALPH_BACKENDS__DATA__", + env_file=".env", + env_file_encoding=core_settings.LOCALE_ENCODING, + ), + } LOCALE_ENCODING: str = "utf8" READ_CHUNK_SIZE: int = 500 @@ -57,10 +60,9 @@ class Config(BaseSettingsConfig): class BaseQuery(BaseModel): """Base query model.""" - class Config: - """Base query model configuration.""" - - extra = "forbid" + model_config = SettingsConfigDict( + extra="forbid", + ) @classmethod def from_string(cls, query: str) -> Self: diff --git a/src/ralph/backends/data/clickhouse.py b/src/ralph/backends/data/clickhouse.py index 58021e6d9..18c72ac29 100755 --- a/src/ralph/backends/data/clickhouse.py +++ b/src/ralph/backends/data/clickhouse.py @@ -22,6 +22,7 @@ from clickhouse_connect.driver.client import Client from clickhouse_connect.driver.exceptions import ClickHouseError from pydantic import BaseModel, PositiveInt, ValidationError +from pydantic_settings import SettingsConfigDict from ralph.backends.data.base import ( BaseDataBackend, @@ -32,7 +33,7 @@ Listable, Writable, ) -from ralph.conf import BaseSettingsConfig, ClientOptions +from ralph.conf import BASE_SETTINGS_CONFIG, ClientOptions from ralph.exceptions import BackendException from ralph.utils import iter_by_batch, parse_iterable_to_dict @@ -77,10 +78,10 @@ class ClickHouseDataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (int): The default chunk size for writing. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__CLICKHOUSE__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__CLICKHOUSE__"), + } HOST: str = "localhost" PORT: int = 8123 @@ -105,11 +106,11 @@ class ClickHouseQuery(BaseQuery): """ select: Union[str, List[str]] = "event" - where: Union[str, List[str], None] - parameters: Union[Dict, None] - limit: Union[int, None] - sort: Union[str, None] - column_oriented: Union[bool, None] = False + where: Optional[Union[str, List[str]]] = None + parameters: Optional[Dict] = None + limit: Optional[int] = None + sort: Optional[str] = None + column_oriented: Optional[bool] = False Settings = TypeVar("Settings", bound=ClickHouseDataBackendSettings) @@ -159,7 +160,7 @@ def client(self) -> Client: database=self.database, username=self.settings.USERNAME, password=self.settings.PASSWORD, - settings=self.settings.CLIENT_OPTIONS.dict(), + settings=self.settings.CLIENT_OPTIONS.model_dump(), ) return self._client diff --git a/src/ralph/backends/data/es.py b/src/ralph/backends/data/es.py index 07560bad3..e004529dd 100644 --- a/src/ralph/backends/data/es.py +++ b/src/ralph/backends/data/es.py @@ -8,6 +8,7 @@ from elasticsearch import ApiError, Elasticsearch, TransportError from elasticsearch.helpers import BulkIndexError, streaming_bulk from pydantic import BaseModel, PositiveInt, ValidationError +from pydantic_settings import SettingsConfigDict from typing_extensions import Self from ralph.backends.data.base import ( @@ -19,7 +20,7 @@ Listable, Writable, ) -from ralph.conf import BaseSettingsConfig, ClientOptions, CommaSeparatedTuple +from ralph.conf import BASE_SETTINGS_CONFIG, ClientOptions, CommaSeparatedTuple from ralph.exceptions import BackendException logger = logging.getLogger(__name__) @@ -52,17 +53,21 @@ class ESDataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (int): The default chunk size for writing batches of documents. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__ES__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__ES__"), + } ALLOW_YELLOW_STATUS: bool = False CLIENT_OPTIONS: ESClientOptions = ESClientOptions() DEFAULT_INDEX: str = "statements" - HOSTS: CommaSeparatedTuple = ("http://localhost:9200",) + HOSTS: CommaSeparatedTuple = ( + "http://localhost:9200" # CommaSeparatedTuple("http://localhost:9200") + ) POINT_IN_TIME_KEEP_ALIVE: str = "1m" - REFRESH_AFTER_WRITE: Union[Literal["false", "true", "wait_for"], bool, str, None] + REFRESH_AFTER_WRITE: Optional[ + Union[Literal["false", "true", "wait_for"], bool, str] + ] = None class ESQueryPit(BaseModel): @@ -74,8 +79,8 @@ class ESQueryPit(BaseModel): time alive. """ - id: Union[str, None] - keep_alive: Union[str, None] + id: Union[str, None] = None + keep_alive: Union[str, None] = None class ESQuery(BaseQuery): @@ -142,7 +147,7 @@ def client(self) -> Elasticsearch: """Create an Elasticsearch client if it doesn't exist.""" if not self._client: self._client = Elasticsearch( - self.settings.HOSTS, **self.settings.CLIENT_OPTIONS.dict() + self.settings.HOSTS, **self.settings.CLIENT_OPTIONS.model_dump() ) return self._client @@ -262,7 +267,7 @@ def _read_dicts( raise BackendException(msg % error) from error limit = query.size - kwargs = query.dict() + kwargs = query.model_dump() count = chunk_size # The first condition is set to comprise either limit as None # (when the backend query does not have `size` parameter), diff --git a/src/ralph/backends/data/fs.py b/src/ralph/backends/data/fs.py index cdaa4fd53..9f508d64b 100644 --- a/src/ralph/backends/data/fs.py +++ b/src/ralph/backends/data/fs.py @@ -8,7 +8,8 @@ from typing import Iterable, Iterator, Optional, Tuple, TypeVar, Union from uuid import uuid4 -from pydantic import PositiveInt +from pydantic import PositiveInt, model_validator +from pydantic_settings import SettingsConfigDict from ralph.backends.data.base import ( BaseDataBackend, @@ -19,7 +20,7 @@ Writable, ) from ralph.backends.data.mixins import HistoryMixin -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException from ralph.utils import now, parse_iterable_to_dict @@ -39,16 +40,27 @@ class FSDataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (int): The default chunk size for writing files. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__FS__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__FS__"), + } DEFAULT_DIRECTORY_PATH: Path = Path(".") DEFAULT_QUERY_STRING: str = "*" READ_CHUNK_SIZE: int = 4096 WRITE_CHUNK_SIZE: int = 4096 + @model_validator(mode="before") + @classmethod + def validate_default_directory_path(cls, values): + """Coerce DEFAULT_DIRECTORY_PATH to `Path`.""" + if "DEFAULT_DIRECTORY_PATH" in values: + if isinstance(values["DEFAULT_DIRECTORY_PATH"], str): + values["DEFAULT_DIRECTORY_PATH"] = Path( + values["DEFAULT_DIRECTORY_PATH"] + ) + return values + Settings = TypeVar("Settings", bound=FSDataBackendSettings) @@ -137,7 +149,6 @@ def list( if not details: for path in paths: yield str(path) - return for path in paths: diff --git a/src/ralph/backends/data/ldp.py b/src/ralph/backends/data/ldp.py index 5ef82995d..e2ac652f4 100644 --- a/src/ralph/backends/data/ldp.py +++ b/src/ralph/backends/data/ldp.py @@ -6,6 +6,7 @@ import ovh import requests from pydantic import PositiveInt +from pydantic_settings import SettingsConfigDict from ralph.backends.data.base import ( BaseDataBackend, @@ -14,7 +15,7 @@ Listable, ) from ralph.backends.data.mixins import HistoryMixin -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException from ralph.utils import now @@ -35,10 +36,10 @@ class LDPDataBackendSettings(BaseDataBackendSettings): SERVICE_NAME (str): The default LDP account name. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__LDP__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__LDP__"), + } APPLICATION_KEY: Optional[str] = None APPLICATION_SECRET: Optional[str] = None diff --git a/src/ralph/backends/data/lrs.py b/src/ralph/backends/data/lrs.py index 2690ad691..be51e6f71 100644 --- a/src/ralph/backends/data/lrs.py +++ b/src/ralph/backends/data/lrs.py @@ -7,6 +7,8 @@ from httpx import Client, HTTPError, HTTPStatusError, RequestError from pydantic import AnyHttpUrl, BaseModel, Field, PositiveInt, parse_obj_as +from pydantic_settings import SettingsConfigDict +from typing_extensions import Annotated from ralph.backends.data.base import ( BaseDataBackend, @@ -16,7 +18,7 @@ Writable, ) from ralph.backends.lrs.base import LRSStatementsQuery -from ralph.conf import BaseSettingsConfig, HeadersParameters +from ralph.conf import BASE_SETTINGS_CONFIG, HeadersParameters from ralph.exceptions import BackendException from ralph.utils import iter_by_batch @@ -26,8 +28,10 @@ class LRSHeaders(HeadersParameters): """Pydantic model for LRS headers.""" - X_EXPERIENCE_API_VERSION: str = Field("1.0.3", alias="X-Experience-API-Version") - CONTENT_TYPE: str = Field("application/json", alias="content-type") + X_EXPERIENCE_API_VERSION: Annotated[ + str, Field("1.0.3", alias="X-Experience-API-Version") + ] + CONTENT_TYPE: Annotated[str, Field("application/json", alias="content-type")] class LRSDataBackendSettings(BaseDataBackendSettings): @@ -45,12 +49,12 @@ class LRSDataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (int): The default chunk size for writing statements. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__LRS__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__LRS__"), + } - BASE_URL: AnyHttpUrl = Field("http://0.0.0.0:8100") + BASE_URL: Annotated[AnyHttpUrl, Field("http://0.0.0.0:8100")] USERNAME: str = "ralph" PASSWORD: str = "secret" HEADERS: LRSHeaders = LRSHeaders() @@ -62,7 +66,7 @@ class StatementResponse(BaseModel): """Pydantic model for `get` statements response.""" statements: Union[List[dict], dict] - more: Optional[str] + more: Optional[str] = None class LRSDataBackend( @@ -94,13 +98,13 @@ def __init__(self, settings: Optional[LRSDataBackendSettings] = None) -> None: def client(self) -> Client: """Create a `httpx.Client` if it doesn't exist.""" if not self._client: - headers = self.settings.HEADERS.dict(by_alias=True) + headers = self.settings.HEADERS.model_dump(by_alias=True) self._client = Client(auth=self.auth, headers=headers) return self._client def status(self) -> DataBackendStatus: """HTTP backend check for server status.""" - status_url = urljoin(self.base_url, self.settings.STATUS_ENDPOINT) + status_url = urljoin(str(self.base_url), self.settings.STATUS_ENDPOINT) try: response = self.client.get(status_url) response.raise_for_status() @@ -169,8 +173,8 @@ def _read_dicts( # Create request URL target = ParseResult( - scheme=urlparse(self.base_url).scheme, - netloc=urlparse(self.base_url).netloc, + scheme=self.base_url.scheme, + netloc=urlparse(str(self.base_url)).netloc, path=target, query="", params="", @@ -179,7 +183,7 @@ def _read_dicts( statements = self._fetch_statements( target=target, - query_params=query.dict(exclude_none=True, exclude_unset=True), + query_params=query.model_dump(exclude_none=True, exclude_unset=True), ) # Iterate through results @@ -230,8 +234,8 @@ def _write_dicts( # noqa: PLR0913 target = self.settings.STATEMENTS_ENDPOINT target = ParseResult( - scheme=urlparse(self.base_url).scheme, - netloc=urlparse(self.base_url).netloc, + scheme=self.base_url.scheme, + netloc=urlparse(str(self.base_url)).netloc, path=target, query="", params="", diff --git a/src/ralph/backends/data/mongo.py b/src/ralph/backends/data/mongo.py index c3c3845cd..33b2af51f 100644 --- a/src/ralph/backends/data/mongo.py +++ b/src/ralph/backends/data/mongo.py @@ -12,7 +12,8 @@ from bson.errors import BSONError from bson.objectid import ObjectId from dateutil.parser import isoparse -from pydantic import MongoDsn, PositiveInt, constr +from pydantic import MongoDsn, PositiveInt, StringConstraints +from pydantic_settings import SettingsConfigDict from pymongo import MongoClient, ReplaceOne from pymongo.collection import Collection from pymongo.errors import ( @@ -22,6 +23,7 @@ InvalidOperation, PyMongoError, ) +from typing_extensions import Annotated from ralph.backends.data.base import ( BaseDataBackend, @@ -32,7 +34,7 @@ Listable, Writable, ) -from ralph.conf import BaseSettingsConfig, ClientOptions +from ralph.conf import BASE_SETTINGS_CONFIG, ClientOptions from ralph.exceptions import BackendException, BackendParameterException from ralph.utils import iter_by_batch @@ -59,16 +61,21 @@ class MongoDataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (int): The default chunk size for writing batches of documents. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__MONGO__" - - CONNECTION_URI: MongoDsn = MongoDsn("mongodb://localhost:27017/", scheme="mongodb") - DEFAULT_DATABASE: constr(regex=r"^[^\s.$/\\\"\x00]+$") = "statements" - DEFAULT_COLLECTION: constr( - regex=r"^(?!.*\.\.)[^.$\x00]+(?:\.[^.$\x00]+)*$" - ) = "marsha" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict( + env_prefix="RALPH_BACKENDS__DATA__MONGO__", regex_engine="python-re" + ), + } # We specify regex_engine as some regex are no longer supported in Pydantic V2 + + CONNECTION_URI: MongoDsn = MongoDsn("mongodb://localhost:27017/") + DEFAULT_DATABASE: Annotated[ + str, StringConstraints(pattern=r"^[^\s.$/\\\"\x00]+$") + ] = "statements" + DEFAULT_COLLECTION: Annotated[ + str, + StringConstraints(pattern=r"^(?!.*\.\.)[^.$\x00]+(?:\.[^.$\x00]+)*$"), + ] = "marsha" CLIENT_OPTIONS: MongoClientOptions = MongoClientOptions() @@ -105,9 +112,8 @@ def __init__(self, settings: Optional[Settings] = None): If `settings` is `None`, a default settings instance is used instead. """ super().__init__(settings) - self.client = MongoClient( - self.settings.CONNECTION_URI, **self.settings.CLIENT_OPTIONS.dict() - ) + host = str(self.settings.CONNECTION_URI) + self.client = MongoClient(host, **self.settings.CLIENT_OPTIONS.model_dump()) self.database = self.client[self.settings.DEFAULT_DATABASE] self.collection = self.database[self.settings.DEFAULT_COLLECTION] @@ -219,7 +225,7 @@ def _read_dicts( ignore_errors: bool, # noqa: ARG002 ) -> Iterator[dict]: """Method called by `self.read` yielding dictionaries. See `self.read`.""" - kwargs = query.dict(exclude_unset=True) + kwargs = query.model_dump(exclude_unset=True) collection = self._get_target_collection(target) try: documents = collection.find(batch_size=chunk_size, **kwargs) diff --git a/src/ralph/backends/data/s3.py b/src/ralph/backends/data/s3.py index ea19ab551..5285474bd 100644 --- a/src/ralph/backends/data/s3.py +++ b/src/ralph/backends/data/s3.py @@ -16,6 +16,7 @@ ResponseStreamingError, ) from pydantic import PositiveInt +from pydantic_settings import SettingsConfigDict from requests_toolbelt import StreamingIterator from ralph.backends.data.base import ( @@ -27,7 +28,7 @@ Writable, ) from ralph.backends.data.mixins import HistoryMixin -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException from ralph.utils import now, parse_iterable_to_dict @@ -49,10 +50,10 @@ class S3DataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (str): The default chunk size for writing objects. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__S3__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__S3__"), + } ACCESS_KEY_ID: Optional[str] = None SECRET_ACCESS_KEY: Optional[str] = None @@ -215,7 +216,7 @@ def _read_bytes( { "backend": self.name, "action": "read", - "id": target + "/" + query, + "id": target.rstrip("/") + "/" + query, "size": response["ContentLength"], "timestamp": now(), } @@ -244,7 +245,7 @@ def _read_dicts( { "backend": self.name, "action": "read", - "id": target + "/" + query, + "id": target.rstrip("/") + "/" + query, "size": response["ContentLength"], "timestamp": now(), } diff --git a/src/ralph/backends/data/swift.py b/src/ralph/backends/data/swift.py index 30ac36d99..656ce6567 100644 --- a/src/ralph/backends/data/swift.py +++ b/src/ralph/backends/data/swift.py @@ -7,6 +7,7 @@ from uuid import uuid4 from pydantic import PositiveInt +from pydantic_settings import SettingsConfigDict from swiftclient.service import ClientException, Connection from ralph.backends.data.base import ( @@ -18,7 +19,7 @@ Writable, ) from ralph.backends.data.mixins import HistoryMixin -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException from ralph.utils import now, parse_iterable_to_dict @@ -45,10 +46,10 @@ class SwiftDataBackendSettings(BaseDataBackendSettings): WRITE_CHUNK_SIZE (str): The default chunk size for writing objects. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__DATA__SWIFT__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__DATA__SWIFT__"), + } AUTH_URL: str = "https://auth.cloud.ovh.net/" DEFAULT_CONTAINER: Optional[str] = None diff --git a/src/ralph/backends/lrs/base.py b/src/ralph/backends/lrs/base.py index e2604979f..6d3f1b1d6 100644 --- a/src/ralph/backends/lrs/base.py +++ b/src/ralph/backends/lrs/base.py @@ -3,10 +3,21 @@ from abc import abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, AsyncIterator, Iterator, List, Literal, Optional, TypeVar, Union +from typing import ( + Any, + AsyncIterator, + Iterator, + List, + Literal, + Optional, + TypeVar, + Union, +) from uuid import UUID from pydantic import BaseModel, Field, NonNegativeInt +from pydantic_settings import SettingsConfigDict +from typing_extensions import Annotated from ralph.backends.data.base import ( BaseAsyncDataBackend, @@ -14,7 +25,7 @@ BaseDataBackendSettings, BaseQuery, ) -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.models.xapi.base.agents import BaseXapiAgent from ralph.models.xapi.base.common import IRI from ralph.models.xapi.base.groups import BaseXapiGroup @@ -23,10 +34,10 @@ class BaseLRSBackendSettings(BaseDataBackendSettings): """LRS backend default configuration.""" - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__LRS__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__LRS__"), + } @dataclass @@ -45,16 +56,18 @@ class LRSStatementsQuery(BaseQuery): https://github.com/adlnet/xAPI-Spec/blob/1.0.3/xAPI-Communication.md#213-get-statements """ - statement_id: Optional[str] = Field(None, alias="statementId") - voided_statement_id: Optional[str] = Field(None, alias="voidedStatementId") - agent: Optional[Union[BaseXapiAgent, BaseXapiGroup]] - verb: Optional[IRI] - activity: Optional[IRI] - registration: Optional[UUID] + statement_id: Annotated[Optional[str], Field(None, alias="statementId")] + voided_statement_id: Annotated[ + Optional[str], Field(None, alias="voidedStatementId") + ] + agent: Optional[Union[BaseXapiAgent, BaseXapiGroup]] = None + verb: Optional[IRI] = None + activity: Optional[IRI] = None + registration: Optional[UUID] = None related_activities: Optional[bool] = False related_agents: Optional[bool] = False - since: Optional[datetime] - until: Optional[datetime] + since: Optional[datetime] = None + until: Optional[datetime] = None limit: Optional[NonNegativeInt] = 0 format: Optional[Literal["ids", "exact", "canonical"]] = "exact" attachments: Optional[bool] = False @@ -67,21 +80,21 @@ class AgentParameters(BaseModel): NB: Agent refers to the data structure, NOT to the LRS query parameter. """ - mbox: Optional[str] - mbox_sha1sum: Optional[str] - openid: Optional[str] - account__name: Optional[str] - account__home_page: Optional[str] + mbox: Optional[str] = None + mbox_sha1sum: Optional[str] = None + openid: Optional[str] = None + account__name: Optional[str] = None + account__home_page: Optional[str] = None class RalphStatementsQuery(LRSStatementsQuery): """Represents a dictionary of possible LRS query parameters.""" - agent: Optional[AgentParameters] = AgentParameters.construct() - search_after: Optional[str] - pit_id: Optional[str] - authority: Optional[AgentParameters] = AgentParameters.construct() - ignore_order: Optional[bool] + agent: Optional[AgentParameters] = AgentParameters.model_construct() + search_after: Optional[str] = None + pit_id: Optional[str] = None + authority: Optional[AgentParameters] = AgentParameters.model_construct() + ignore_order: Optional[bool] = None Settings = TypeVar("Settings", bound=BaseLRSBackendSettings) diff --git a/src/ralph/backends/lrs/clickhouse.py b/src/ralph/backends/lrs/clickhouse.py index 1ce7f2485..0613049ea 100644 --- a/src/ralph/backends/lrs/clickhouse.py +++ b/src/ralph/backends/lrs/clickhouse.py @@ -3,6 +3,8 @@ import logging from typing import Generator, Iterator, List +from pydantic_settings import SettingsConfigDict + from ralph.backends.data.clickhouse import ( ClickHouseDataBackend, ClickHouseDataBackendSettings, @@ -14,7 +16,7 @@ RalphStatementsQuery, StatementQueryResult, ) -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException logger = logging.getLogger(__name__) @@ -29,10 +31,10 @@ class ClickHouseLRSBackendSettings( IDS_CHUNK_SIZE (int): The chunk size for querying by ids. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__LRS__CLICKHOUSE__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__LRS__CLICKHOUSE__"), + } IDS_CHUNK_SIZE: int = 10000 @@ -44,7 +46,11 @@ class ClickHouseLRSBackend( def query_statements(self, params: RalphStatementsQuery) -> StatementQueryResult: """Return the statements query payload using xAPI parameters.""" - ch_params = params.dict(exclude_none=True) + ch_params = params.model_dump(exclude_none=True) + + if "statement_id" in ch_params: + ch_params["statementId"] = ch_params["statement_id"] + where = [] if params.statement_id: @@ -95,6 +101,7 @@ def query_statements(self, params: RalphStatementsQuery) -> StatementQueryResult limit=params.limit, sort=order_by, ) + try: clickhouse_response = list( self.read( @@ -162,7 +169,7 @@ def _add_agent_filters( return if not isinstance(agent_params, dict): - agent_params = agent_params.dict() + agent_params = agent_params.model_dump() if agent_params.get("mbox"): ch_params[f"{target_field}__mbox"] = agent_params.get("mbox") diff --git a/src/ralph/backends/lrs/es.py b/src/ralph/backends/lrs/es.py index 4ad98fb72..3d75cda69 100644 --- a/src/ralph/backends/lrs/es.py +++ b/src/ralph/backends/lrs/es.py @@ -3,6 +3,8 @@ import logging from typing import Iterator, List +from pydantic_settings import SettingsConfigDict + from ralph.backends.data.es import ( ESDataBackend, ESDataBackendSettings, @@ -16,7 +18,7 @@ RalphStatementsQuery, StatementQueryResult, ) -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException logger = logging.getLogger(__name__) @@ -25,10 +27,10 @@ class ESLRSBackendSettings(BaseLRSBackendSettings, ESDataBackendSettings): """Elasticsearch LRS backend default configuration.""" - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__LRS__ES__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__LRS__ES__"), + } class ESLRSBackend(BaseLRSBackend[ESLRSBackendSettings], ESDataBackend): @@ -87,7 +89,7 @@ def get_query(params: RalphStatementsQuery) -> ESQuery: es_query_filters += [{"range": {"timestamp": {"lte": params.until}}}] es_query = { - "pit": ESQueryPit.construct(id=params.pit_id), + "pit": ESQueryPit.model_construct(id=params.pit_id), "size": params.limit, "sort": [{"timestamp": {"order": "asc" if params.ascending else "desc"}}], } @@ -101,7 +103,7 @@ def get_query(params: RalphStatementsQuery) -> ESQuery: es_query["search_after"] = params.search_after.split("|") # Note: `params` fields are validated thus we skip their validation in ESQuery. - return ESQuery.construct(**es_query) + return ESQuery.model_construct(**es_query) @staticmethod def _add_agent_filters( @@ -112,7 +114,7 @@ def _add_agent_filters( return if not isinstance(agent_params, dict): - agent_params = agent_params.dict() + agent_params = agent_params.model_dump() if agent_params.get("mbox"): field = f"{target_field}.mbox.keyword" diff --git a/src/ralph/backends/lrs/fs.py b/src/ralph/backends/lrs/fs.py index 13eb245c4..b7364967c 100644 --- a/src/ralph/backends/lrs/fs.py +++ b/src/ralph/backends/lrs/fs.py @@ -6,6 +6,8 @@ from typing import Iterable, List, Literal, Optional, Union from uuid import UUID +from pydantic_settings import SettingsConfigDict + from ralph.backends.data.base import BaseOperationType from ralph.backends.data.fs import FSDataBackend, FSDataBackendSettings from ralph.backends.lrs.base import ( @@ -15,7 +17,7 @@ RalphStatementsQuery, StatementQueryResult, ) -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG logger = logging.getLogger(__name__) @@ -27,10 +29,10 @@ class FSLRSBackendSettings(BaseLRSBackendSettings, FSDataBackendSettings): DEFAULT_LRS_FILE (str): The default LRS filename to store statements. """ - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__LRS__FS__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__LRS__FS__"), + } DEFAULT_LRS_FILE: str = "fs_lrs.jsonl" @@ -110,7 +112,7 @@ def _add_filter_by_agent( return if not isinstance(agent, dict): - agent = agent.dict() + agent = agent.model_dump() FSLRSBackend._add_filter_by_mbox(filters, agent.get("mbox", None), related) FSLRSBackend._add_filter_by_sha1sum( filters, agent.get("mbox_sha1sum", None), related @@ -133,7 +135,7 @@ def _add_filter_by_authority( return if not isinstance(authority, dict): - authority = authority.dict() + authority = authority.model_dump() FSLRSBackend._add_filter_by_mbox( filters, authority.get("mbox", None), field="authority" ) diff --git a/src/ralph/backends/lrs/mongo.py b/src/ralph/backends/lrs/mongo.py index 0436b7b43..5a1295949 100644 --- a/src/ralph/backends/lrs/mongo.py +++ b/src/ralph/backends/lrs/mongo.py @@ -4,6 +4,7 @@ from typing import Iterator, List from bson.objectid import ObjectId +from pydantic_settings import SettingsConfigDict from pymongo import ASCENDING, DESCENDING from ralph.backends.data.mongo import ( @@ -18,7 +19,7 @@ RalphStatementsQuery, StatementQueryResult, ) -from ralph.conf import BaseSettingsConfig +from ralph.conf import BASE_SETTINGS_CONFIG from ralph.exceptions import BackendException, BackendParameterException logger = logging.getLogger(__name__) @@ -27,10 +28,10 @@ class MongoLRSBackendSettings(BaseLRSBackendSettings, MongoDataBackendSettings): """MongoDB LRS backend default configuration.""" - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_prefix = "RALPH_BACKENDS__LRS__MONGO__" + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict(env_prefix="RALPH_BACKENDS__LRS__MONGO__"), + } class MongoLRSBackend(BaseLRSBackend[MongoLRSBackendSettings], MongoDataBackend): @@ -110,7 +111,7 @@ def get_query(params: RalphStatementsQuery) -> MongoQuery: ] # Note: `params` fields are validated thus we skip MongoQuery validation. - return MongoQuery.construct( + return MongoQuery.model_construct( filter=mongo_query_filters, limit=params.limit, sort=mongo_query_sort ) @@ -129,7 +130,7 @@ def _add_agent_filters( return if not isinstance(agent_params, dict): - agent_params = agent_params.dict() + agent_params = agent_params.model_dump() if agent_params.get("mbox"): key = f"_source.{target_field}.mbox" diff --git a/src/ralph/cli.py b/src/ralph/cli.py index 9a2aab95a..59091d049 100644 --- a/src/ralph/cli.py +++ b/src/ralph/cli.py @@ -26,6 +26,7 @@ # dependencies are not installed. pass from click_option_group import optgroup +from pydantic import AnyUrl from ralph import __version__ as ralph_version from ralph.backends.data.base import ( @@ -40,7 +41,7 @@ get_cli_write_backends, get_lrs_backends, ) -from ralph.conf import ClientOptions, CommaSeparatedTuple, HeadersParameters, settings +from ralph.conf import ClientOptions, HeadersParameters, settings from ralph.logger import configure_logging from ralph.models.converter import Converter from ralph.models.selector import ModelSelector @@ -78,6 +79,16 @@ def convert(self, value, param, ctx): return value +class AnyUrlParamType(click.ParamType): + """AnyUrl parameter type.""" + + name = "URL" + + def convert(self, value, param, ctx): # noqa: ARG002 + """Return str representation of AnyUrl instance.""" + return str(value) + + class CommaSeparatedKeyValueParamType(click.ParamType): """Comma-separated key=value parameter type.""" @@ -256,9 +267,9 @@ def wrapper(command): backend_names = [] for backend_name, backend in backends.items(): backend_names.append(backend_name) - fields = backend.settings_class.__fields__.items() + fields = backend.settings_class.model_fields.items() for field_name, field in sorted(fields, key=lambda x: x[0], reverse=True): - field_type = field.type_ + field_type = field.annotation field_name = ( # noqa: PLW2901 f"{backend_name}-{field_name.lower()}".replace("_", "-") ) @@ -272,7 +283,7 @@ def wrapper(command): option_kwargs["is_flag"] = True elif field_type is dict: option_kwargs["type"] = CommaSeparatedKeyValueParamType() - elif field_type is CommaSeparatedTuple: + elif field_type is tuple: # CommaSeparatedTuple option_kwargs["type"] = CommaSeparatedTupleParamType() elif isclass(field_type) and issubclass(field_type, ClientOptions): option_kwargs["type"] = ClientOptionsParamType(field_type) @@ -280,6 +291,8 @@ def wrapper(command): option_kwargs["type"] = HeadersParametersParamType(field_type) elif field_type is Path: option_kwargs["type"] = click.Path() + elif field_type is AnyUrl: + option_kwargs["type"] = AnyUrlParamType() command = optgroup.option(option.lower(), **option_kwargs)(command) @@ -459,23 +472,28 @@ def auth( # noqa: PLR0913 auth_file.parent.mkdir(parents=True, exist_ok=True) auth_file.touch() - users = ServerUsersCredentials.parse_obj([]) + users = ServerUsersCredentials.model_validate([]) # Parse credentials file if not empty if auth_file.stat().st_size: - users = ServerUsersCredentials.parse_file(auth_file) - users += ServerUsersCredentials.parse_obj( + with open(auth_file, encoding=settings.LOCALE_ENCODING) as f: + users = ServerUsersCredentials.model_validate_json(f.read()) + + users += ServerUsersCredentials.model_validate( [ credentials, ] ) - auth_file.write_text(users.json(indent=2), encoding=settings.LOCALE_ENCODING) + + auth_file.write_text( + users.model_dump_json(indent=2), encoding=settings.LOCALE_ENCODING + ) logger.info("User %s has been added to: %s", username, settings.AUTH_FILE) else: click.echo( ( f"Copy/paste the following credentials to your LRS authentication " f"file located in: {settings.AUTH_FILE}\n" - f"{credentials.json(indent=2)}" + f"{credentials.model_dump_json(indent=2)}" ) ) diff --git a/src/ralph/conf.py b/src/ralph/conf.py index 964d8ea35..5c88dc776 100644 --- a/src/ralph/conf.py +++ b/src/ralph/conf.py @@ -1,22 +1,28 @@ """Configurations for Ralph.""" import io -import sys from enum import Enum from pathlib import Path -from typing import List, Sequence, Tuple, Union - -from pydantic import AnyHttpUrl, AnyUrl, BaseModel, BaseSettings, Extra, root_validator +from typing import List, Literal, Optional, Tuple, Union + +from pydantic import ( + AfterValidator, + AnyHttpUrl, + AnyUrl, + BaseModel, + ConfigDict, + Field, + StringConstraints, + model_validator, + parse_obj_as, +) +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Annotated from ralph.exceptions import ConfigurationException from .utils import import_string -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - try: from click import get_app_dir except ImportError: @@ -27,23 +33,21 @@ get_app_dir = Mock(return_value=".") -MODEL_PATH_SEPARATOR = "__" +MODEL_PATH_SEPARATOR = "__" -class BaseSettingsConfig: - """Pydantic model for BaseSettings Configuration.""" +NonEmptyStr = Annotated[str, Field(min_length=1)] +NonEmptyStrictStr = Annotated[str, StringConstraints(min_length=1, strict=True)] - case_sensitive = True - env_nested_delimiter = "__" - env_prefix = "RALPH_" - extra = "ignore" +BASE_SETTINGS_CONFIG = SettingsConfigDict( + case_sensitive=True, env_nested_delimiter="__", env_prefix="RALPH_", extra="ignore" +) class CoreSettings(BaseSettings): """Pydantic model for Ralph's core settings.""" - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" + model_config = BASE_SETTINGS_CONFIG APP_DIR: Path = get_app_dir("ralph") LOCALE_ENCODING: str = getattr(io, "LOCALE_ENCODING", "utf8") @@ -52,30 +56,25 @@ class Config(BaseSettingsConfig): core_settings = CoreSettings() -class CommaSeparatedTuple(str): - """Pydantic field type validating comma-separated strings or lists/tuples.""" +def validate_comma_separated_tuple(value: Union[str, Tuple[str, ...]]) -> Tuple[str]: + """Checks whether the value is a comma separated string or a tuple.""" + if isinstance(value, tuple): + return value - @classmethod - def __get_validators__(cls): # noqa: D105 - def validate(value: Union[str, Sequence[str]]) -> Sequence[str]: - """Check whether the value is a comma-separated string or a list/tuple.""" - if isinstance(value, (tuple, list)): - return tuple(value) + if isinstance(value, str): + return tuple(value.split(",")) - if isinstance(value, str): - return tuple(value.split(",")) + raise TypeError("Invalid comma separated tuple") - raise TypeError("Invalid comma-separated list") - yield validate +CommaSeparatedTuple = Annotated[ + Union[str, Tuple[str, ...]], AfterValidator(validate_comma_separated_tuple) +] class InstantiableSettingsItem(BaseModel): """Pydantic model for a settings configuration item that can be instantiated.""" - class Config: # noqa: D106 - underscore_attrs_are_private = True - _class_path: str = None def get_instance(self, **init_parameters): @@ -86,20 +85,16 @@ def get_instance(self, **init_parameters): class ClientOptions(BaseModel): """Pydantic model for additional client options.""" - class Config: # noqa: D106 - extra = Extra.forbid + model_config = ConfigDict(extra="forbid") class HeadersParameters(BaseModel): """Pydantic model for headers parameters.""" - class Config: # noqa: D106 - extra = Extra.allow + model_config = ConfigDict(extra="allow") # Active parser Settings. - - class ESParserSettings(InstantiableSettingsItem): """Pydantic model for Elasticsearch parser configuration settings.""" @@ -122,13 +117,10 @@ class ParserSettings(BaseModel): class XapiForwardingConfigurationSettings(BaseModel): """Pydantic model for xAPI forwarding configuration item.""" - class Config: # noqa: D106 - min_anystr_length = 1 - url: AnyUrl is_active: bool - basic_username: str - basic_password: str + basic_username: NonEmptyStr + basic_password: NonEmptyStr max_retries: int timeout: float @@ -140,51 +132,39 @@ class AuthBackend(str, Enum): OIDC = "oidc" -class AuthBackends(Tuple[AuthBackend]): - """Model representing a tuple of authentication backends.""" +def validate_auth_backends( + value: Union[AuthBackend, Tuple[AuthBackend], List[AuthBackend]] +) -> Tuple[AuthBackend]: + """Check whether the value is a comma separated string or a list/tuple.""" + if isinstance(value, (tuple, list)): + return tuple(AuthBackend(val.lower()) for val in value) - @classmethod - def __get_validators__(cls): - """Check whether the value is a comma-separated string or a tuple representing - an AuthBackend. - """ # noqa: D205 - - def validate( - auth_backends: Union[ - str, AuthBackend, Tuple[AuthBackend], List[AuthBackend] - ] - ) -> Tuple[AuthBackend]: - """Check whether the value is a comma-separated string or a list/tuple.""" - if isinstance(auth_backends, str): - return tuple( - AuthBackend(value.lower()) for value in auth_backends.split(",") - ) + if isinstance(value, str): + return tuple(AuthBackend(val) for val in value.lower().split(",")) - if isinstance(auth_backends, AuthBackend): - return (auth_backends,) + raise TypeError("Invalid comma separated tuple") - if isinstance(auth_backends, (tuple, list)): - return tuple(auth_backends) - raise TypeError("Invalid comma-separated list") - - yield validate +AuthBackends = Annotated[ + Union[str, Tuple[str, ...], List[str]], AfterValidator(validate_auth_backends) +] class Settings(BaseSettings): """Pydantic model for Ralph's global environment & configuration settings.""" - class Config(BaseSettingsConfig): - """Pydantic Configuration.""" - - env_file = ".env" - env_file_encoding = core_settings.LOCALE_ENCODING + model_config = { + **BASE_SETTINGS_CONFIG, + **SettingsConfigDict( + env_file=".env", env_file_encoding=core_settings.LOCALE_ENCODING + ), + } _CORE: CoreSettings = core_settings AUTH_FILE: Path = _CORE.APP_DIR / "auth.json" - AUTH_CACHE_MAX_SIZE = 100 - AUTH_CACHE_TTL = 3600 - CONVERTER_EDX_XAPI_UUID_NAMESPACE: str = None + AUTH_CACHE_MAX_SIZE: int = 100 + AUTH_CACHE_TTL: int = 3600 + CONVERTER_EDX_XAPI_UUID_NAMESPACE: Optional[str] = None EXECUTION_ENVIRONMENT: str = "development" HISTORY_FILE: Path = _CORE.APP_DIR / "history.json" LOGGING: dict = { @@ -219,9 +199,9 @@ class Config(BaseSettingsConfig): }, } PARSERS: ParserSettings = ParserSettings() - RUNSERVER_AUTH_BACKENDS: AuthBackends = AuthBackends([AuthBackend.BASIC]) - RUNSERVER_AUTH_OIDC_AUDIENCE: str = None - RUNSERVER_AUTH_OIDC_ISSUER_URI: AnyHttpUrl = None + RUNSERVER_AUTH_BACKENDS: AuthBackends = parse_obj_as(AuthBackends, "Basic") + RUNSERVER_AUTH_OIDC_AUDIENCE: Optional[str] = None + RUNSERVER_AUTH_OIDC_ISSUER_URI: Optional[AnyHttpUrl] = None RUNSERVER_BACKEND: Literal[ "async_es", "async_mongo", "clickhouse", "es", "fs", "mongo" ] = "es" @@ -232,7 +212,7 @@ class Config(BaseSettingsConfig): LRS_RESTRICT_BY_AUTHORITY: bool = False LRS_RESTRICT_BY_SCOPES: bool = False SENTRY_CLI_TRACES_SAMPLE_RATE: float = 1.0 - SENTRY_DSN: str = None + SENTRY_DSN: Optional[str] = None SENTRY_IGNORE_HEALTH_CHECKS: bool = False SENTRY_LRS_TRACES_SAMPLE_RATE: float = 1.0 XAPI_FORWARDINGS: List[XapiForwardingConfigurationSettings] = [] @@ -247,18 +227,25 @@ def LOCALE_ENCODING(self) -> str: """Return Ralph's default locale encoding.""" return self._CORE.LOCALE_ENCODING - @root_validator(allow_reuse=True) + @model_validator(mode="before") @classmethod - def check_restriction_compatibility(cls, values): + def validate_paths(cls, values): + """Coerce fields to `Path`.""" + for field in ["AUTH_FILE", "HISTORY_FILE"]: + if field in values: + if isinstance(values[field], str): + values[field] = Path(values[field]) + return values + + @model_validator(mode="after") + def check_restriction_compatibility(self): """Raise an error if scopes are being used without authority restriction.""" - if values.get("LRS_RESTRICT_BY_SCOPES") and not values.get( - "LRS_RESTRICT_BY_AUTHORITY" - ): + if self.LRS_RESTRICT_BY_SCOPES and not self.LRS_RESTRICT_BY_AUTHORITY: raise ConfigurationException( "LRS_RESTRICT_BY_AUTHORITY must be set to True if using " "LRS_RESTRICT_BY_SCOPES=True" ) - return values + return self settings = Settings() diff --git a/src/ralph/models/converter.py b/src/ralph/models/converter.py index e2577c2af..07112d0b7 100644 --- a/src/ralph/models/converter.py +++ b/src/ralph/models/converter.py @@ -137,6 +137,7 @@ def convert_dict_event( if value not in [None, "", {}]: set_dict_value_from_path(converted_event, conversion_item.dest, value) logger.debug("Intermediate converted event: %s", converted_event) + return conversion_set.__dest__(**converted_event) @@ -198,7 +199,7 @@ def convert( for event_str in input_file: try: total += 1 - yield self._convert_event(event_str).json( + yield self._convert_event(event_str).model_dump_json( exclude_none=True, by_alias=True ) success += 1 @@ -216,7 +217,7 @@ def convert( if not ignore_errors: raise err except ValidationError as err: - message = f"Converted event is not a valid ({err.model}) model" + message = "Converted event is not valid" self._log_error(message, event_str, err) if not ignore_errors: raise err diff --git a/src/ralph/models/edx/base.py b/src/ralph/models/edx/base.py index 0c51ba98e..15ee8c20e 100644 --- a/src/ralph/models/edx/base.py +++ b/src/ralph/models/edx/base.py @@ -1,24 +1,24 @@ """Base event model definitions.""" -import sys from datetime import datetime from ipaddress import IPv4Address from pathlib import Path -from typing import Dict, Optional, Union +from typing import Dict, Literal, Optional, Union -from pydantic import AnyHttpUrl, BaseModel, constr - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal +from pydantic import ( + AnyHttpUrl, + BaseModel, + ConfigDict, + Field, + StringConstraints, +) +from typing_extensions import Annotated class BaseModelWithConfig(BaseModel): """Pydantic model for base configuration shared among all models.""" - class Config: # noqa: D106 - extra = "forbid" + model_config = ConfigDict(extra="forbid", coerce_numbers_to_str=True) class ContextModuleField(BaseModelWithConfig): @@ -29,12 +29,19 @@ class ContextModuleField(BaseModelWithConfig): display_name (str): Consists of a short description or title of the component. """ - usage_key: constr(regex=r"^block-v1:.+\+.+\+.+type@.+@[a-f0-9]{32}$") + usage_key: Annotated[ + str, StringConstraints(pattern=r"^block-v1:.+\+.+\+.+type@.+@[a-f0-9]{32}$") + ] display_name: str original_usage_key: Optional[ - constr(regex=r"^block-v1:.+\+.+\+.+type@problem\+block@[a-f0-9]{32}$") - ] - original_usage_version: Optional[str] + Annotated[ + str, + StringConstraints( + pattern=r"^block-v1:.+\+.+\+.+type@problem\+block@[a-f0-9]{32}$" + ), + ] + ] = None + original_usage_version: Optional[str] = None class BaseContextField(BaseModelWithConfig): @@ -79,12 +86,12 @@ class BaseContextField(BaseModelWithConfig): `request.META['PATH_INFO']` """ - course_id: constr(regex=r"^$|^course-v1:.+\+.+\+.+$") - course_user_tags: Optional[Dict[str, str]] - module: Optional[ContextModuleField] + course_id: Annotated[str, Field(pattern=r"^$|^course-v1:.+\+.+\+.+$")] + course_user_tags: Optional[Dict[str, str]] = None + module: Optional[ContextModuleField] = None org_id: str path: Path - user_id: Union[int, Literal[""], None] + user_id: Union[int, Literal[""], None] = None class AbstractBaseEventField(BaseModelWithConfig): @@ -149,7 +156,9 @@ class BaseEdxModel(BaseModelWithConfig): In JSON the value is `null` instead of `None`. """ - username: Union[constr(min_length=2, max_length=30), Literal[""]] + username: Union[ + Annotated[str, StringConstraints(min_length=2, max_length=30)], Literal[""] + ] ip: Union[IPv4Address, Literal[""]] agent: str host: str diff --git a/src/ralph/models/edx/browser.py b/src/ralph/models/edx/browser.py index 38f5d17ed..136af9df3 100644 --- a/src/ralph/models/edx/browser.py +++ b/src/ralph/models/edx/browser.py @@ -3,7 +3,8 @@ import sys from typing import Union -from pydantic import AnyUrl, constr +from pydantic import AnyUrl, StringConstraints +from typing_extensions import Annotated from .base import BaseEdxModel @@ -29,4 +30,6 @@ class BaseBrowserModel(BaseEdxModel): event_source: Literal["browser"] page: AnyUrl - session: Union[constr(regex=r"^[a-f0-9]{32}$"), Literal[""]] + session: Union[ + Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{32}$")], Literal[""] + ] diff --git a/src/ralph/models/edx/converters/xapi/enrollment.py b/src/ralph/models/edx/converters/xapi/enrollment.py index 7f1feb145..b4500b9a1 100644 --- a/src/ralph/models/edx/converters/xapi/enrollment.py +++ b/src/ralph/models/edx/converters/xapi/enrollment.py @@ -22,7 +22,10 @@ def _get_conversion_items(self) -> Set[ConversionItem]: ConversionItem( "object__id", "event__course_id", - lambda course_id: f"{self.platform_url}/courses/{course_id}/info", + lambda course_id: ( + f"{self.platform_url.rstrip('/')}/courses/" + f"{course_id.strip('/')}/info" + ), ), ConversionItem( "context__contextActivities__category", diff --git a/src/ralph/models/edx/converters/xapi/server.py b/src/ralph/models/edx/converters/xapi/server.py index 6fb94f4c4..531b99baf 100644 --- a/src/ralph/models/edx/converters/xapi/server.py +++ b/src/ralph/models/edx/converters/xapi/server.py @@ -26,7 +26,7 @@ def _get_conversion_items(self) -> Set[ConversionItem]: ConversionItem( "object__id", "event_type", - lambda event_type: self.platform_url + event_type, + lambda event_type: self.platform_url.rstrip("/") + event_type, ), } ) diff --git a/src/ralph/models/edx/converters/xapi/video.py b/src/ralph/models/edx/converters/xapi/video.py index 0abccadeb..73ebfebbd 100644 --- a/src/ralph/models/edx/converters/xapi/video.py +++ b/src/ralph/models/edx/converters/xapi/video.py @@ -46,7 +46,7 @@ def _get_conversion_items(self) -> Set[ConversionItem]: ConversionItem( "object__id", None, - lambda event: self.platform_url + lambda event: self.platform_url.rstrip("/") + "/xblock/block-v1:" + event["context"]["course_id"] + "-course-v1:+type@video+block@" diff --git a/src/ralph/models/edx/enrollment/fields/contexts.py b/src/ralph/models/edx/enrollment/fields/contexts.py index 478086935..8952ef741 100644 --- a/src/ralph/models/edx/enrollment/fields/contexts.py +++ b/src/ralph/models/edx/enrollment/fields/contexts.py @@ -1,7 +1,6 @@ """Enrollment event models context fields definitions.""" import sys -from typing import Union from ...base import BaseContextField @@ -22,7 +21,7 @@ class EdxCourseEnrollmentUpgradeClickedContextField(BaseContextField): enrollment mode when the user clicked Challenge Yourself. """ - mode: Union[Literal["audit"], Literal["honor"]] + mode: Literal["audit", "honor"] class EdxCourseEnrollmentUpgradeSucceededContextField(BaseContextField): diff --git a/src/ralph/models/edx/enrollment/fields/events.py b/src/ralph/models/edx/enrollment/fields/events.py index 60ef60c62..012e7be31 100644 --- a/src/ralph/models/edx/enrollment/fields/events.py +++ b/src/ralph/models/edx/enrollment/fields/events.py @@ -28,4 +28,4 @@ class EnrollmentEventField(AbstractBaseEventField): mode: Union[ Literal["audit"], Literal["honor"], Literal["professional"], Literal["verified"] ] - user_id: Union[int, Literal[""], None] + user_id: Union[int, Literal[""], None] = None diff --git a/src/ralph/models/edx/enrollment/statements.py b/src/ralph/models/edx/enrollment/statements.py index a381eecff..6fee5c8f7 100644 --- a/src/ralph/models/edx/enrollment/statements.py +++ b/src/ralph/models/edx/enrollment/statements.py @@ -35,7 +35,6 @@ class EdxCourseEnrollmentActivated(BaseServerModel): __selector__ = selector( event_source="server", event_type="edx.course.enrollment.activated" ) - event: Union[ Json[EnrollmentEventField], EnrollmentEventField, @@ -84,8 +83,8 @@ class EdxCourseEnrollmentModeChanged(BaseServerModel): ) event: Union[ - Json[EnrollmentEventField], EnrollmentEventField, + Json[EnrollmentEventField], ] event_type: Literal["edx.course.enrollment.mode_changed"] name: Literal["edx.course.enrollment.mode_changed"] diff --git a/src/ralph/models/edx/navigational/fields/events.py b/src/ralph/models/edx/navigational/fields/events.py index 9d9e5a8ef..66b9881a2 100644 --- a/src/ralph/models/edx/navigational/fields/events.py +++ b/src/ralph/models/edx/navigational/fields/events.py @@ -1,6 +1,7 @@ """Navigational event field definition.""" -from pydantic import constr +from pydantic import StringConstraints +from typing_extensions import Annotated from ...base import AbstractBaseEventField @@ -20,11 +21,14 @@ class NavigationalEventField(AbstractBaseEventField): being navigated away from. """ - id: constr( - regex=( - r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type" - r"@sequential\+block@[a-f0-9]{32}$" - ) - ) + id: Annotated[ + str, + StringConstraints( + pattern=( + r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type" + r"@sequential\+block@[a-f0-9]{32}$" + ) + ), + ] new: int old: int diff --git a/src/ralph/models/edx/navigational/statements.py b/src/ralph/models/edx/navigational/statements.py index 0778ba563..adfec02f6 100644 --- a/src/ralph/models/edx/navigational/statements.py +++ b/src/ralph/models/edx/navigational/statements.py @@ -3,7 +3,7 @@ import sys from typing import Union -from pydantic import Json, validator +from pydantic import Json, field_validator from ralph.models.selector import selector @@ -72,7 +72,7 @@ class UISeqNext(BaseBrowserModel): event_type: Literal["seq_next"] name: Literal["seq_next"] - @validator("event") + @field_validator("event") @classmethod def validate_next_jump_event_field( cls, value: Union[Json[NavigationalEventField], NavigationalEventField] @@ -103,7 +103,7 @@ class UISeqPrev(BaseBrowserModel): event_type: Literal["seq_prev"] name: Literal["seq_prev"] - @validator("event") + @field_validator("event") @classmethod def validate_prev_jump_event_field( cls, value: Union[Json[NavigationalEventField], NavigationalEventField] diff --git a/src/ralph/models/edx/open_response_assessment/fields/events.py b/src/ralph/models/edx/open_response_assessment/fields/events.py index 0eecb4d38..923b721e8 100644 --- a/src/ralph/models/edx/open_response_assessment/fields/events.py +++ b/src/ralph/models/edx/open_response_assessment/fields/events.py @@ -5,7 +5,8 @@ from typing import Dict, List, Optional, Union from uuid import UUID -from pydantic import constr +from pydantic import StringConstraints +from typing_extensions import Annotated from ralph.models.edx.base import AbstractBaseEventField, BaseModelWithConfig @@ -29,12 +30,15 @@ class ORAGetPeerSubmissionEventField(AbstractBaseEventField): available. """ - course_id: constr(max_length=255) - item_id: constr( - regex=(r"^block-v1:.+\+.+\+.+type@openassessment+block@[a-f0-9]{32}$") - ) + course_id: Annotated[str, StringConstraints(max_length=255)] + item_id: Annotated[ + str, + StringConstraints( + pattern=(r"^block-v1:.+\+.+\+.+type@openassessment+block@[a-f0-9]{32}$") + ), + ] requesting_student_id: str - submission_returned_uuid: Union[str, None] + submission_returned_uuid: Union[str, None] = None class ORAGetSubmissionForStaffGradingEventField(AbstractBaseEventField): @@ -53,10 +57,13 @@ class ORAGetSubmissionForStaffGradingEventField(AbstractBaseEventField): Currently, set to `full-grade`. """ # noqa: D205 - item_id: constr( - regex=(r"^block-v1:.+\+.+\+.+type@openassessment+block@[a-f0-9]{32}$") - ) - submission_returned_uuid: Union[str, None] + item_id: Annotated[ + str, + StringConstraints( + pattern=(r"^block-v1:.+\+.+\+.+type@openassessment+block@[a-f0-9]{32}$") + ), + ] + submission_returned_uuid: Union[str, None] = None requesting_staff_id: str type: Literal["full-grade"] @@ -86,7 +93,7 @@ class ORAAssessEventPartsField(BaseModelWithConfig): option: str criterion: ORAAssessEventPartsCriterionField - feedback: Optional[str] + feedback: Optional[str] = None class ORAAssessEventRubricField(BaseModelWithConfig): @@ -102,7 +109,7 @@ class ORAAssessEventRubricField(BaseModelWithConfig): assess the response. """ - content_hash: constr(regex=r"^[a-f0-9]{1,40}$") + content_hash: Annotated[str, StringConstraints(pattern=r"^[a-f0-9]{1,40}$")] class ORAAssessEventField(AbstractBaseEventField): @@ -131,7 +138,7 @@ class ORAAssessEventField(AbstractBaseEventField): parts: List[ORAAssessEventPartsField] rubric: ORAAssessEventRubricField scored_at: datetime - scorer_id: constr(max_length=40) + scorer_id: Annotated[str, StringConstraints(max_length=40)] score_type: Literal["PE", "SE", "ST"] submission_uuid: UUID @@ -178,8 +185,8 @@ class ORACreateSubmissionEventAnswerField(BaseModelWithConfig): """ # noqa: D205 parts: List[Dict[Literal["text"], str]] - file_keys: Optional[List[str]] - files_descriptions: Optional[List[str]] + file_keys: Optional[List[str]] = None + files_descriptions: Optional[List[str]] = None class ORACreateSubmissionEventField(AbstractBaseEventField): @@ -214,7 +221,7 @@ class ORASaveSubmissionEventSavedResponseField(BaseModelWithConfig): """ text: str - file_upload_key: Optional[str] + file_upload_key: Optional[str] = None class ORASaveSubmissionEventField(AbstractBaseEventField): @@ -259,6 +266,6 @@ class ORAUploadFileEventField(BaseModelWithConfig): fileType (str): Consists of the MIME type of the uploaded file. """ - fileName: constr(max_length=255) + fileName: Annotated[str, StringConstraints(max_length=255)] fileSize: int fileType: str diff --git a/src/ralph/models/edx/peer_instruction/fields/events.py b/src/ralph/models/edx/peer_instruction/fields/events.py index 83b8af10e..dc4e2ac44 100644 --- a/src/ralph/models/edx/peer_instruction/fields/events.py +++ b/src/ralph/models/edx/peer_instruction/fields/events.py @@ -1,6 +1,7 @@ """Peer instruction event field definition.""" -from pydantic import constr +from pydantic import StringConstraints +from typing_extensions import Annotated from ...base import AbstractBaseEventField @@ -18,5 +19,5 @@ class PeerInstructionEventField(AbstractBaseEventField): """ answer: int - rationale: constr(max_length=12500) + rationale: Annotated[str, StringConstraints(max_length=12500)] truncated: bool diff --git a/src/ralph/models/edx/problem_interaction/fields/events.py b/src/ralph/models/edx/problem_interaction/fields/events.py index 8ba3b0e16..dd401d005 100644 --- a/src/ralph/models/edx/problem_interaction/fields/events.py +++ b/src/ralph/models/edx/problem_interaction/fields/events.py @@ -4,7 +4,8 @@ from datetime import datetime from typing import Dict, List, Optional, Union -from pydantic import constr +from pydantic import Field +from typing_extensions import Annotated from ...base import AbstractBaseEventField, BaseModelWithConfig @@ -41,13 +42,13 @@ class CorrectMap(BaseModelWithConfig): queuestate (json): see QueueStateField. """ - answervariable: Union[Literal[None], None, str] - correctness: Union[Literal["correct"], Literal["incorrect"]] - hint: Optional[str] - hintmode: Optional[Union[Literal["on_request"], Literal["always"]]] + answervariable: Optional[str] = None + correctness: Literal["correct", "incorrect"] + hint: Optional[str] = None + hintmode: Optional[Literal["on_request", "always"]] = None msg: str - npoints: Optional[int] - queuestate: Optional[QueueState] + npoints: Optional[int] = None + queuestate: Optional[QueueState] = None class State(BaseModelWithConfig): @@ -62,10 +63,10 @@ class State(BaseModelWithConfig): """ correct_map: Dict[ - constr(regex=r"^[a-f0-9]{32}_[0-9]_[0-9]$"), + Annotated[str, Field(pattern=r"^[a-f0-9]{32}_[0-9]_[0-9]$")], CorrectMap, ] - done: Optional[bool] + done: Optional[bool] = None input_state: dict seed: int student_answers: dict @@ -135,21 +136,21 @@ class EdxProblemHintFeedbackDisplayedEventField(AbstractBaseEventField): `student_answer` response. Consists either of `single` or `compound` value. """ - choice_all: Optional[List[str]] + choice_all: Optional[List[str]] = None correctness: bool hint_label: str hints: List[dict] module_id: str problem_part_id: str - question_type: Union[ - Literal["stringresponse"], - Literal["choiceresponse"], - Literal["multiplechoiceresponse"], - Literal["numericalresponse"], - Literal["optionresponse"], + question_type: Literal[ + "stringresponse", + "choiceresponse", + "multiplechoiceresponse", + "numericalresponse", + "optionresponse", ] student_answer: List[str] - trigger_type: Union[Literal["single"], Literal["compound"]] + trigger_type: Literal["single", "compound"] class ProblemCheckEventField(AbstractBaseEventField): @@ -170,26 +171,29 @@ class ProblemCheckEventField(AbstractBaseEventField): """ answers: Dict[ - constr(regex=r"^[a-f0-9]{32}_[0-9]_[0-9]$"), - Union[List[str], str], + Annotated[str, Field(pattern=r"^[a-f0-9]{32}_[0-9]_[0-9]$")], + Union[str, List[str]], ] attempts: int correct_map: Dict[ - constr(regex=r"^[a-f0-9]{32}_[0-9]_[0-9]$"), + Annotated[str, Field(pattern=r"^[a-f0-9]{32}_[0-9]_[0-9]$")], CorrectMap, ] grade: int max_grade: int - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] state: State submission: Dict[ - constr(regex=r"^[a-f0-9]{32}_[0-9]_[0-9]$"), + Annotated[str, Field(pattern=r"^[a-f0-9]{32}_[0-9]_[0-9]$")], SubmissionAnswerField, ] - success: Union[Literal["correct"], Literal["incorrect"]] + success: Literal["correct", "incorrect"] class ProblemCheckFailEventField(AbstractBaseEventField): @@ -204,14 +208,17 @@ class ProblemCheckFailEventField(AbstractBaseEventField): """ answers: Dict[ - constr(regex=r"^[a-f0-9]{32}_[0-9]_[0-9]$"), - Union[List[str], str], + Annotated[str, Field(pattern=r"^[a-f0-9]{32}_[0-9]_[0-9]$")], + Union[str, List[str]], + ] + failure: Literal["closed", "unreset"] + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), ] - failure: Union[Literal["closed"], Literal["unreset"]] - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) state: State @@ -235,12 +242,15 @@ class ProblemRescoreEventField(AbstractBaseEventField): new_total: int orig_score: int orig_total: int - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] state: State - success: Union[Literal["correct"], Literal["incorrect"]] + success: Literal["correct", "incorrect"] class ProblemRescoreFailEventField(AbstractBaseEventField): @@ -252,11 +262,14 @@ class ProblemRescoreFailEventField(AbstractBaseEventField): state (json): see StateField. """ - failure: Union[Literal["closed"], Literal["unreset"]] - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + failure: Literal["closed", "unreset"] + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] state: State @@ -293,10 +306,13 @@ class ResetProblemEventField(AbstractBaseEventField): new_state: State old_state: State - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] class ResetProblemFailEventField(AbstractBaseEventField): @@ -308,12 +324,15 @@ class ResetProblemFailEventField(AbstractBaseEventField): problem_id (str): Consists of the ID of the problem being reset. """ - failure: Union[Literal["closed"], Literal["not_done"]] + failure: Literal["closed", "not_done"] old_state: State - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] class SaveProblemFailEventField(AbstractBaseEventField): @@ -328,11 +347,14 @@ class SaveProblemFailEventField(AbstractBaseEventField): """ answers: Dict[str, Union[int, str, list, dict]] - failure: Union[Literal["closed"], Literal["done"]] - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + failure: Literal["closed", "done"] + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] state: State @@ -347,10 +369,13 @@ class SaveProblemSuccessEventField(AbstractBaseEventField): """ answers: Dict[str, Union[int, str, list, dict]] - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] state: State @@ -361,7 +386,10 @@ class ShowAnswerEventField(AbstractBaseEventField): problem_id (str): Consists of the ID of the problem being shown. """ - problem_id: constr( - regex=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" - r"type@problem\+block@[a-f0-9]{32}$" - ) + problem_id: Annotated[ + str, + Field( + pattern=r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" + r"type@problem\+block@[a-f0-9]{32}$" + ), + ] diff --git a/src/ralph/models/edx/textbook_interaction/fields/events.py b/src/ralph/models/edx/textbook_interaction/fields/events.py index ec34f82d6..0c1d6bdab 100644 --- a/src/ralph/models/edx/textbook_interaction/fields/events.py +++ b/src/ralph/models/edx/textbook_interaction/fields/events.py @@ -4,6 +4,7 @@ from typing import Optional, Union from pydantic import Field, constr +from typing_extensions import Annotated from ...base import AbstractBaseEventField @@ -24,7 +25,7 @@ class TextbookInteractionBaseEventField(AbstractBaseEventField): page: int chapter: constr( - regex=(r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$") + pattern=(r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$") ) @@ -71,7 +72,7 @@ class TextbookPdfChapterNavigatedEventField(AbstractBaseEventField): name: Literal["textbook.pdf.chapter.navigated"] chapter: constr( - regex=(r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$") + pattern=(r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$") ) chapter_title: str @@ -95,7 +96,7 @@ class TextbookPdfZoomButtonsChangedEventField(TextbookInteractionBaseEventField) """ name: Literal["textbook.pdf.zoom.buttons.changed"] - direction: Union[Literal["in"], Literal["out"]] + direction: Literal["in", "out"] class TextbookPdfZoomMenuChangedEventField(TextbookInteractionBaseEventField): @@ -146,7 +147,7 @@ class TextbookPdfPageScrolledEventField(TextbookInteractionBaseEventField): """ name: Literal["textbook.pdf.page.scrolled"] - direction: Union[Literal["up"], Literal["down"]] + direction: Literal["up", "down"] class TextbookPdfSearchExecutedEventField(TextbookInteractionBaseEventField): @@ -257,13 +258,12 @@ class BookEventField(AbstractBaseEventField): """ chapter: constr( - regex=(r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$") + pattern=(r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$") ) - name: Union[ - Literal["textbook.pdf.page.loaded"], Literal["textbook.pdf.page.navigatednext"] - ] + name: Literal["textbook.pdf.page.loaded", "textbook.pdf.page.navigatednext"] new: int old: Optional[int] - type: Union[Literal["gotopage"], Literal["prevpage"], Literal["nextpage"]] = Field( - alias="type" - ) + type: Annotated[ + Literal["gotopage", "prevpage", "nextpage"], + Field(alias="type"), + ] diff --git a/src/ralph/models/edx/video/fields/events.py b/src/ralph/models/edx/video/fields/events.py index 786a57957..4786ebbba 100644 --- a/src/ralph/models/edx/video/fields/events.py +++ b/src/ralph/models/edx/video/fields/events.py @@ -2,6 +2,8 @@ import sys +from pydantic import ConfigDict, NonNegativeFloat + from ...base import AbstractBaseEventField if sys.version_info >= (3, 8): @@ -20,8 +22,7 @@ class VideoBaseEventField(AbstractBaseEventField): course creators, or the system-generated hash code otherwise. """ - class Config: # noqa: D106 - extra = "allow" + model_config = ConfigDict(extra="allow", coerce_numbers_to_str=True) code: str id: str @@ -35,7 +36,7 @@ class PlayVideoEventField(VideoBaseEventField): the statement was emitted. """ - currentTime: float + currentTime: NonNegativeFloat class PauseVideoEventField(VideoBaseEventField): @@ -46,7 +47,7 @@ class PauseVideoEventField(VideoBaseEventField): the statement was emitted. """ - currentTime: float + currentTime: NonNegativeFloat class SeekVideoEventField(VideoBaseEventField): @@ -61,8 +62,8 @@ class SeekVideoEventField(VideoBaseEventField): within the video, either `onCaptionSeek` or `onSlideSeek` value. """ - new_time: float - old_time: float + new_time: NonNegativeFloat + old_time: NonNegativeFloat type: str @@ -74,7 +75,7 @@ class StopVideoEventField(VideoBaseEventField): the statement was emitted. """ - currentTime: float + currentTime: NonNegativeFloat class VideoHideTranscriptEventField(VideoBaseEventField): @@ -85,7 +86,7 @@ class VideoHideTranscriptEventField(VideoBaseEventField): the statement was emitted. """ - current_time: float + current_time: NonNegativeFloat class VideoShowTranscriptEventField(VideoBaseEventField): @@ -96,7 +97,7 @@ class VideoShowTranscriptEventField(VideoBaseEventField): the statement was emitted. """ - current_time: float + current_time: NonNegativeFloat class SpeedChangeVideoEventField(VideoBaseEventField): @@ -107,6 +108,6 @@ class SpeedChangeVideoEventField(VideoBaseEventField): the statement was emitted. """ - currentTime: float + currentTime: NonNegativeFloat new_speed: Literal["0.75", "1.0", "1.25", "1.50", "2.0"] old_speed: Literal["0.75", "1.0", "1.25", "1.50", "2.0"] diff --git a/src/ralph/models/edx/video/statements.py b/src/ralph/models/edx/video/statements.py index 6b3552a2b..26d206775 100644 --- a/src/ralph/models/edx/video/statements.py +++ b/src/ralph/models/edx/video/statements.py @@ -66,7 +66,7 @@ class UIPlayVideo(BaseBrowserModel): PlayVideoEventField, ] event_type: Literal["play_video"] - name: Optional[Literal["play_video", "edx.video.played"]] + name: Optional[Literal["play_video", "edx.video.played"]] = None class UIPauseVideo(BaseBrowserModel): @@ -88,7 +88,7 @@ class UIPauseVideo(BaseBrowserModel): PauseVideoEventField, ] event_type: Literal["pause_video"] - name: Optional[Literal["pause_video", "edx.video.paused"]] + name: Optional[Literal["pause_video", "edx.video.paused"]] = None class UISeekVideo(BaseBrowserModel): @@ -111,7 +111,7 @@ class UISeekVideo(BaseBrowserModel): SeekVideoEventField, ] event_type: Literal["seek_video"] - name: Optional[Literal["seek_video", "edx.video.position.changed"]] + name: Optional[Literal["seek_video", "edx.video.position.changed"]] = None class UIStopVideo(BaseBrowserModel): @@ -133,7 +133,7 @@ class UIStopVideo(BaseBrowserModel): StopVideoEventField, ] event_type: Literal["stop_video"] - name: Optional[Literal["stop_video", "edx.video.stopped"]] + name: Optional[Literal["stop_video", "edx.video.stopped"]] = None class UIHideTranscript(BaseBrowserModel): @@ -200,7 +200,7 @@ class UISpeedChangeVideo(BaseBrowserModel): SpeedChangeVideoEventField, ] event_type: Literal["speed_change_video"] - name: Optional[Literal["speed_change_video"]] + name: Optional[Literal["speed_change_video"]] = None class UIVideoHideCCMenu(BaseBrowserModel): @@ -221,7 +221,7 @@ class UIVideoHideCCMenu(BaseBrowserModel): VideoBaseEventField, ] event_type: Literal["video_hide_cc_menu"] - name: Optional[Literal["video_hide_cc_menu"]] + name: Optional[Literal["video_hide_cc_menu"]] = None class UIVideoShowCCMenu(BaseBrowserModel): @@ -244,4 +244,4 @@ class UIVideoShowCCMenu(BaseBrowserModel): VideoBaseEventField, ] event_type: Literal["video_show_cc_menu"] - name: Optional[Literal["video_show_cc_menu"]] + name: Optional[Literal["video_show_cc_menu"]] = None diff --git a/src/ralph/models/validator.py b/src/ralph/models/validator.py index 78bebe7d5..919d700fa 100644 --- a/src/ralph/models/validator.py +++ b/src/ralph/models/validator.py @@ -41,7 +41,7 @@ def validate( if fail_on_unknown: raise err except ValidationError as err: - message = f"Input event is not a valid {err.model.__name__} event." + message = "Input event is not valid." self._log_error(message, event_str, err) if not ignore_errors: raise BadFormatException(message) from err @@ -60,7 +60,6 @@ def get_first_valid_model(self, event: dict) -> Any: return model(**event) except ValidationError as err: error = err - raise error def _validate_event(self, event_str: str) -> Any: @@ -76,7 +75,7 @@ def _validate_event(self, event_str: str) -> Any: event_str (str): The cleaned JSON-formatted input event_str. """ event = json.loads(event_str) - return self.get_first_valid_model(event).json() + return self.get_first_valid_model(event).model_dump_json() @staticmethod def _log_error( diff --git a/src/ralph/models/xapi/base/agents.py b/src/ralph/models/xapi/base/agents.py index 9f6ce53f5..31d8388c1 100644 --- a/src/ralph/models/xapi/base/agents.py +++ b/src/ralph/models/xapi/base/agents.py @@ -4,9 +4,9 @@ from abc import ABC from typing import Optional, Union -from pydantic import StrictStr +from ralph.conf import NonEmptyStrictStr +from ralph.models.xapi.config import BaseModelWithConfig -from ..config import BaseModelWithConfig from .common import IRI from .ifi import ( BaseXapiAccountIFI, @@ -30,7 +30,7 @@ class BaseXapiAgentAccount(BaseModelWithConfig): """ homePage: IRI - name: StrictStr + name: NonEmptyStrictStr class BaseXapiAgentCommonProperties(BaseModelWithConfig, ABC): @@ -43,8 +43,8 @@ class BaseXapiAgentCommonProperties(BaseModelWithConfig, ABC): name (str): Consists of the full name of the Agent. """ - objectType: Optional[Literal["Agent"]] - name: Optional[StrictStr] + objectType: Optional[Literal["Agent"]] = None + name: Optional[NonEmptyStrictStr] = None class BaseXapiAgentWithMbox(BaseXapiAgentCommonProperties, BaseXapiMboxIFI): diff --git a/src/ralph/models/xapi/base/attachments.py b/src/ralph/models/xapi/base/attachments.py index 91ffdf93a..7ae7d37cb 100644 --- a/src/ralph/models/xapi/base/attachments.py +++ b/src/ralph/models/xapi/base/attachments.py @@ -23,8 +23,8 @@ class BaseXapiAttachment(BaseModelWithConfig): usageType: IRI display: LanguageMap - description: Optional[LanguageMap] + description: Optional[LanguageMap] = None contentType: str length: int sha2: str - fileUrl: Optional[AnyUrl] + fileUrl: Optional[AnyUrl] = None diff --git a/src/ralph/models/xapi/base/common.py b/src/ralph/models/xapi/base/common.py index 14c27d1e7..0c0b62485 100644 --- a/src/ralph/models/xapi/base/common.py +++ b/src/ralph/models/xapi/base/common.py @@ -1,52 +1,60 @@ """Common for xAPI base definitions.""" -from typing import Dict, Generator, Type +from typing import Dict, Type, Union from langcodes import tag_is_valid -from pydantic import StrictStr, validate_email +from pydantic import RootModel, model_validator, validate_email from rfc3987 import parse +from ralph.conf import NonEmptyStrictStr -class IRI(str): + +class IRI(RootModel[Union["IRI", str]]): """Pydantic custom data type validating RFC 3987 IRIs.""" - @classmethod - def __get_validators__(cls) -> Generator: # noqa: D105 - def validate(iri: str) -> Type["IRI"]: - """Check whether the provided IRI is a valid RFC 3987 IRI.""" - parse(iri, rule="IRI") - return cls(iri) + def __hash__(self): # noqa: D105 + return hash(str(self.root)) - yield validate + def __str__(self): # noqa: D105 + return str(self.root) + + @model_validator(mode="before") + @classmethod + def validate_iri(cls, iri): + """Check whether the provided IRI is a valid RFC 3987 IRI.""" + parse(str(iri), rule="IRI") + return str(iri) -class LanguageTag(str): +class LanguageTag(RootModel[Union[str, "LanguageTag"]]): """Pydantic custom data type validating RFC 5646 Language tags.""" - @classmethod - def __get_validators__(cls) -> Generator: # noqa: D105 - def validate(tag: str) -> Type["LanguageTag"]: - """Check whether the provided tag is a valid RFC 5646 Language tag.""" - if not tag_is_valid(tag): - raise TypeError("Invalid RFC 5646 Language tag") - return cls(tag) + def __hash__(self): # noqa: D105 + return hash(str(self.root)) + + def __str__(self): # noqa: D105 + return str(self.root) - yield validate + @model_validator(mode="before") + @classmethod + def validate_language_tag(cls, tag): + """Check whether the provided tag is a valid RFC 5646 Language tag.""" + if not tag_is_valid(str(tag)): + raise TypeError("Invalid RFC 5646 Language tag") + return str(tag) -LanguageMap = Dict[LanguageTag, StrictStr] +LanguageMap = Dict[LanguageTag, NonEmptyStrictStr] -class MailtoEmail(str): +class MailtoEmail(RootModel[str]): """Pydantic custom data type validating `mailto:email` format.""" - @classmethod - def __get_validators__(cls) -> Generator: # noqa: D105 - def validate(mailto: str) -> Type["MailtoEmail"]: - """Check whether the provided value follows the `mailto:email` format.""" - if not mailto.startswith("mailto:"): - raise TypeError("Invalid `mailto:email` value") - valid = validate_email(mailto[7:]) - return cls(f"mailto:{valid[1]}") - - yield validate + @model_validator(mode="after") + def validate(self) -> Type["MailtoEmail"]: + """Check whether the provided value follows the `mailto:email` format.""" + if not self.root.startswith("mailto:"): + raise TypeError("Invalid `mailto:email` value") + valid = validate_email(self.root[7:]) + self.root = f"mailto:{valid[1]}" + return self diff --git a/src/ralph/models/xapi/base/contexts.py b/src/ralph/models/xapi/base/contexts.py index febd78754..2e3456c10 100644 --- a/src/ralph/models/xapi/base/contexts.py +++ b/src/ralph/models/xapi/base/contexts.py @@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Union from uuid import UUID -from pydantic import StrictStr +from ralph.conf import NonEmptyStrictStr from ..config import BaseModelWithConfig from .agents import BaseXapiAgent @@ -25,10 +25,10 @@ class BaseXapiContextContextActivities(BaseModelWithConfig): properties. """ - parent: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] - grouping: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] - category: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] - other: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] + parent: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] = None + grouping: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] = None + category: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] = None + other: Optional[Union[BaseXapiActivity, List[BaseXapiActivity]]] = None class BaseXapiContext(BaseModelWithConfig): @@ -46,12 +46,12 @@ class BaseXapiContext(BaseModelWithConfig): extensions (dict): Consists of a dictionary of other properties as needed. """ - registration: Optional[UUID] - instructor: Optional[BaseXapiAgent] - team: Optional[BaseXapiGroup] - contextActivities: Optional[BaseXapiContextContextActivities] - revision: Optional[StrictStr] - platform: Optional[StrictStr] - language: Optional[LanguageTag] - statement: Optional[BaseXapiStatementRef] - extensions: Optional[Dict[IRI, Union[str, int, bool, list, dict, None]]] + registration: Optional[UUID] = None + instructor: Optional[BaseXapiAgent] = None + team: Optional[BaseXapiGroup] = None + contextActivities: Optional[BaseXapiContextContextActivities] = None + revision: Optional[NonEmptyStrictStr] = None + platform: Optional[NonEmptyStrictStr] = None + language: Optional[LanguageTag] = None + statement: Optional[BaseXapiStatementRef] = None + extensions: Optional[Dict[IRI, Union[str, int, bool, list, dict, None]]] = None diff --git a/src/ralph/models/xapi/base/groups.py b/src/ralph/models/xapi/base/groups.py index 73c320058..e47874b6a 100644 --- a/src/ralph/models/xapi/base/groups.py +++ b/src/ralph/models/xapi/base/groups.py @@ -4,8 +4,6 @@ from abc import ABC from typing import List, Optional, Union -from pydantic import StrictStr - from ..config import BaseModelWithConfig from .agents import BaseXapiAgent from .ifi import ( @@ -20,6 +18,8 @@ else: from typing_extensions import Literal +from ralph.conf import NonEmptyStrictStr + class BaseXapiGroupCommonProperties(BaseModelWithConfig, ABC): """Pydantic model for core `Group` type property. @@ -32,7 +32,7 @@ class BaseXapiGroupCommonProperties(BaseModelWithConfig, ABC): """ objectType: Literal["Group"] - name: Optional[StrictStr] + name: Optional[NonEmptyStrictStr] = None class BaseXapiAnonymousGroup(BaseXapiGroupCommonProperties): diff --git a/src/ralph/models/xapi/base/ifi.py b/src/ralph/models/xapi/base/ifi.py index 642e933c7..c9b71da57 100644 --- a/src/ralph/models/xapi/base/ifi.py +++ b/src/ralph/models/xapi/base/ifi.py @@ -1,6 +1,9 @@ """Base xAPI `Inverse Functional Identifier` definitions.""" -from pydantic import AnyUrl, StrictStr, constr +from pydantic import StringConstraints +from typing_extensions import Annotated + +from ralph.conf import NonEmptyStrictStr from ..config import BaseModelWithConfig from .common import IRI, MailtoEmail @@ -15,7 +18,7 @@ class BaseXapiAccount(BaseModelWithConfig): """ homePage: IRI - name: StrictStr + name: NonEmptyStrictStr class BaseXapiMboxIFI(BaseModelWithConfig): @@ -25,6 +28,8 @@ class BaseXapiMboxIFI(BaseModelWithConfig): mbox (MailtoEmail): Consists of the Agent's email address. """ + # pattern = r'mailto:\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b' + # mbox: Annotated[str, Field(pattern=pattern)]# mbox: MailtoEmail @@ -35,7 +40,7 @@ class BaseXapiMboxSha1SumIFI(BaseModelWithConfig): mbox_sha1sum (str): Consists of the SHA1 hash of the Agent's email address. """ - mbox_sha1sum: constr(regex=r"^[0-9a-f]{40}$") + mbox_sha1sum: Annotated[str, StringConstraints(pattern=r"^[0-9a-f]{40}$")] class BaseXapiOpenIdIFI(BaseModelWithConfig): @@ -45,7 +50,7 @@ class BaseXapiOpenIdIFI(BaseModelWithConfig): openid (URI): Consists of an openID that uniquely identifies the Agent. """ - openid: AnyUrl + openid: str class BaseXapiAccountIFI(BaseModelWithConfig): diff --git a/src/ralph/models/xapi/base/objects.py b/src/ralph/models/xapi/base/objects.py index 74180040f..7b6a5cd89 100644 --- a/src/ralph/models/xapi/base/objects.py +++ b/src/ralph/models/xapi/base/objects.py @@ -36,10 +36,10 @@ class BaseXapiSubStatement(BaseModelWithConfig): verb: BaseXapiVerb object: BaseXapiUnnestedObject objectType: Literal["SubStatement"] - result: Optional[BaseXapiResult] - context: Optional[BaseXapiContext] - timestamp: Optional[datetime] - attachments: Optional[List[BaseXapiAttachment]] + result: Optional[BaseXapiResult] = None + context: Optional[BaseXapiContext] = None + timestamp: Optional[datetime] = None + attachments: Optional[List[BaseXapiAttachment]] = None BaseXapiObject = Union[ diff --git a/src/ralph/models/xapi/base/results.py b/src/ralph/models/xapi/base/results.py index bd3d49ec9..fd33dcf26 100644 --- a/src/ralph/models/xapi/base/results.py +++ b/src/ralph/models/xapi/base/results.py @@ -4,7 +4,10 @@ from decimal import Decimal from typing import Any, Dict, Optional, Union -from pydantic import StrictBool, StrictStr, conint, root_validator +from pydantic import Field, StrictBool, model_validator +from typing_extensions import Annotated + +from ralph.conf import NonEmptyStrictStr from ..config import BaseModelWithConfig from .common import IRI @@ -20,29 +23,23 @@ class BaseXapiResultScore(BaseModelWithConfig): max (Decimal): Consists of the highest possible score. """ - scaled: Optional[conint(ge=-1, le=1)] - raw: Optional[Decimal] - min: Optional[Decimal] - max: Optional[Decimal] + scaled: Optional[Annotated[int, Field(ge=-1, le=1)]] = None + raw: Optional[Decimal] = None + min: Optional[Decimal] = None + max: Optional[Decimal] = None - @root_validator - @classmethod - def check_raw_min_max_relation(cls, values: Any) -> Any: + @model_validator(mode="after") + def check_raw_min_max_relation(self) -> Any: """Check the relationship `min < raw < max`.""" - raw_value = values.get("raw", None) - min_value = values.get("min", None) - max_value = values.get("max", None) - - if min_value: - if max_value and min_value > max_value: + if self.min: + if self.max and self.min > self.max: raise ValueError("min cannot be greater than max") - if raw_value and min_value > raw_value: + if self.raw and self.min > self.raw: raise ValueError("min cannot be greater than raw") - if max_value: - if raw_value and raw_value > max_value: + if self.max: + if self.raw and self.raw > self.max: raise ValueError("raw cannot be greater than max") - - return values + return self class BaseXapiResult(BaseModelWithConfig): @@ -58,9 +55,9 @@ class BaseXapiResult(BaseModelWithConfig): extensions (dict): Consists of a dictionary of other properties as needed. """ - score: Optional[BaseXapiResultScore] - success: Optional[StrictBool] - completion: Optional[StrictBool] - response: Optional[StrictStr] - duration: Optional[timedelta] - extensions: Optional[Dict[IRI, Union[str, int, bool, list, dict, None]]] + score: Optional[BaseXapiResultScore] = None + success: Optional[StrictBool] = None + completion: Optional[StrictBool] = None + response: Optional[NonEmptyStrictStr] = None + duration: Optional[timedelta] = None + extensions: Optional[Dict[IRI, Union[str, int, bool, list, dict, None]]] = None diff --git a/src/ralph/models/xapi/base/statements.py b/src/ralph/models/xapi/base/statements.py index a86b31134..869d5378c 100644 --- a/src/ralph/models/xapi/base/statements.py +++ b/src/ralph/models/xapi/base/statements.py @@ -4,7 +4,8 @@ from typing import Any, List, Optional, Union from uuid import UUID -from pydantic import constr, root_validator +from pydantic import StringConstraints, model_validator +from typing_extensions import Annotated from ..config import BaseModelWithConfig from .agents import BaseXapiAgent @@ -33,19 +34,19 @@ class BaseXapiStatement(BaseModelWithConfig): attachments (list): Consists of a list of attachments. """ - id: Optional[UUID] + id: Optional[UUID] = None actor: Union[BaseXapiAgent, BaseXapiGroup] verb: BaseXapiVerb object: BaseXapiObject - result: Optional[BaseXapiResult] - context: Optional[BaseXapiContext] - timestamp: Optional[datetime] - stored: Optional[datetime] - authority: Optional[Union[BaseXapiAgent, BaseXapiGroup]] - version: constr(regex=r"^1\.0\.[0-9]+$") = "1.0.0" - attachments: Optional[List[BaseXapiAttachment]] + result: Optional[BaseXapiResult] = None + context: Optional[BaseXapiContext] = None + timestamp: Optional[datetime] = None + stored: Optional[datetime] = None + authority: Optional[Union[BaseXapiAgent, BaseXapiGroup]] = None + version: Annotated[str, StringConstraints(pattern=r"^1\.0\.[0-9]+$")] = "1.0.0" + attachments: Optional[List[BaseXapiAttachment]] = None - @root_validator(pre=True) + @model_validator(mode="before") @classmethod def check_absence_of_empty_and_invalid_values(cls, values: Any) -> Any: """Check the model for empty and invalid values. diff --git a/src/ralph/models/xapi/base/unnested_objects.py b/src/ralph/models/xapi/base/unnested_objects.py index f1113ab5a..12ae0b209 100644 --- a/src/ralph/models/xapi/base/unnested_objects.py +++ b/src/ralph/models/xapi/base/unnested_objects.py @@ -1,19 +1,16 @@ """Base xAPI `Object` definitions (1).""" -import sys -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, StrictStr, constr, validator +from pydantic import AnyUrl, StringConstraints, field_validator +from typing_extensions import Annotated + +from ralph.conf import NonEmptyStrictStr from ..config import BaseModelWithConfig from .common import IRI, LanguageMap -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - class BaseXapiActivityDefinition(BaseModelWithConfig): """Pydantic model for `Activity` type `definition` property. @@ -26,11 +23,11 @@ class BaseXapiActivityDefinition(BaseModelWithConfig): extensions (dict): Consists of a dictionary of other properties as needed. """ - name: Optional[LanguageMap] - description: Optional[LanguageMap] - type: Optional[IRI] - moreInfo: Optional[AnyUrl] - extensions: Optional[Dict[IRI, Union[str, int, bool, list, dict, None]]] + name: Optional[LanguageMap] = None + description: Optional[LanguageMap] = None + type: Optional[IRI] = None + moreInfo: Optional[AnyUrl] = None + extensions: Optional[Dict[IRI, Union[str, int, bool, list, dict, None]]] = None class BaseXapiInteractionComponent(BaseModelWithConfig): @@ -41,8 +38,8 @@ class BaseXapiInteractionComponent(BaseModelWithConfig): description (LanguageMap): Consists of the description of the interaction. """ - id: constr(regex=r"^[^\s]+$") - description: Optional[LanguageMap] + id: Annotated[str, StringConstraints(pattern=r"^[^\s]+$")] # + description: Optional[LanguageMap] = None class BaseXapiActivityInteractionDefinition(BaseXapiActivityDefinition): @@ -72,18 +69,18 @@ class BaseXapiActivityInteractionDefinition(BaseXapiActivityDefinition): "numeric", "other", ] - correctResponsesPattern: Optional[List[StrictStr]] - choices: Optional[List[BaseXapiInteractionComponent]] - scale: Optional[List[BaseXapiInteractionComponent]] - source: Optional[List[BaseXapiInteractionComponent]] - target: Optional[List[BaseXapiInteractionComponent]] - steps: Optional[List[BaseXapiInteractionComponent]] - - @validator("choices", "scale", "source", "target", "steps") + correctResponsesPattern: Optional[List[NonEmptyStrictStr]] = None + choices: Optional[List[BaseXapiInteractionComponent]] = None + scale: Optional[List[BaseXapiInteractionComponent]] = None + source: Optional[List[BaseXapiInteractionComponent]] = None + target: Optional[List[BaseXapiInteractionComponent]] = None + steps: Optional[List[BaseXapiInteractionComponent]] = None + + @field_validator("choices", "scale", "source", "target", "steps", mode="after") @classmethod - def check_unique_ids(cls, value: Any) -> None: + def check_unique_ids(cls, value: Optional[List[Any]]) -> None: """Check the uniqueness of interaction components IDs.""" - if len(value) != len({x.id for x in value}): + if value and (len(value) != len({x.id for x in value if x})): raise ValueError("Duplicate InteractionComponents are not valid") @@ -98,13 +95,13 @@ class BaseXapiActivity(BaseModelWithConfig): """ id: IRI - objectType: Optional[Literal["Activity"]] + objectType: Optional[Literal["Activity"]] = None definition: Optional[ Union[ BaseXapiActivityDefinition, BaseXapiActivityInteractionDefinition, ] - ] + ] = None class BaseXapiStatementRef(BaseModelWithConfig): diff --git a/src/ralph/models/xapi/base/verbs.py b/src/ralph/models/xapi/base/verbs.py index aa91a6bea..2b86a738d 100644 --- a/src/ralph/models/xapi/base/verbs.py +++ b/src/ralph/models/xapi/base/verbs.py @@ -15,4 +15,4 @@ class BaseXapiVerb(BaseModelWithConfig): """ id: IRI - display: Optional[LanguageMap] + display: Optional[LanguageMap] = None diff --git a/src/ralph/models/xapi/concepts/verbs/acrossx_profile.py b/src/ralph/models/xapi/concepts/verbs/acrossx_profile.py index f0d4d5e5b..317aafd75 100644 --- a/src/ralph/models/xapi/concepts/verbs/acrossx_profile.py +++ b/src/ralph/models/xapi/concepts/verbs/acrossx_profile.py @@ -23,4 +23,4 @@ class PostedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/acrossx/verbs/posted" ] = "https://w3id.org/xapi/acrossx/verbs/posted" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["posted"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["posted"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/activity_streams_vocabulary.py b/src/ralph/models/xapi/concepts/verbs/activity_streams_vocabulary.py index 10b6cef1c..79ab6fd8d 100644 --- a/src/ralph/models/xapi/concepts/verbs/activity_streams_vocabulary.py +++ b/src/ralph/models/xapi/concepts/verbs/activity_streams_vocabulary.py @@ -21,7 +21,7 @@ class JoinVerb(BaseXapiVerb): """ id: Literal["http://activitystrea.ms/join"] = "http://activitystrea.ms/join" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["joined"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["joined"]]] = None class LeaveVerb(BaseXapiVerb): @@ -33,4 +33,4 @@ class LeaveVerb(BaseXapiVerb): """ id: Literal["http://activitystrea.ms/leave"] = "http://activitystrea.ms/leave" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["left"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["left"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/adl_vocabulary.py b/src/ralph/models/xapi/concepts/verbs/adl_vocabulary.py index da1b6804c..3c3dcb9f7 100644 --- a/src/ralph/models/xapi/concepts/verbs/adl_vocabulary.py +++ b/src/ralph/models/xapi/concepts/verbs/adl_vocabulary.py @@ -23,7 +23,7 @@ class AskedVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/asked" ] = "http://adlnet.gov/expapi/verbs/asked" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["asked"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["asked"]]] = None class AnsweredVerb(BaseXapiVerb): @@ -37,7 +37,7 @@ class AnsweredVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/answered" ] = "http://adlnet.gov/expapi/verbs/answered" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["answered"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["answered"]]] = None class RegisteredVerb(BaseXapiVerb): @@ -51,4 +51,4 @@ class RegisteredVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/registered" ] = "http://adlnet.gov/expapi/verbs/registered" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["registered"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["registered"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/navy_common_reference_profile.py b/src/ralph/models/xapi/concepts/verbs/navy_common_reference_profile.py index 53f027ceb..400f36dc1 100644 --- a/src/ralph/models/xapi/concepts/verbs/navy_common_reference_profile.py +++ b/src/ralph/models/xapi/concepts/verbs/navy_common_reference_profile.py @@ -23,7 +23,7 @@ class AccessedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/netc/verbs/accessed" ] = "https://w3id.org/xapi/netc/verbs/accessed" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["accessed"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["accessed"]]] = None class UploadedVerb(BaseXapiVerb): @@ -37,4 +37,4 @@ class UploadedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/netc/verbs/uploaded" ] = "https://w3id.org/xapi/netc/verbs/uploaded" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["uploaded"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["uploaded"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/scorm_profile.py b/src/ralph/models/xapi/concepts/verbs/scorm_profile.py index 12dbd1b11..0c377064a 100644 --- a/src/ralph/models/xapi/concepts/verbs/scorm_profile.py +++ b/src/ralph/models/xapi/concepts/verbs/scorm_profile.py @@ -23,7 +23,7 @@ class CompletedVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/completed" ] = "http://adlnet.gov/expapi/verbs/completed" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["completed"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["completed"]]] = None class InitializedVerb(BaseXapiVerb): @@ -37,7 +37,7 @@ class InitializedVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/initialized" ] = "http://adlnet.gov/expapi/verbs/initialized" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["initialized"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["initialized"]]] = None class InteractedVerb(BaseXapiVerb): @@ -51,7 +51,7 @@ class InteractedVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/interacted" ] = "http://adlnet.gov/expapi/verbs/interacted" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["interacted"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["interacted"]]] = None class TerminatedVerb(BaseXapiVerb): @@ -65,4 +65,4 @@ class TerminatedVerb(BaseXapiVerb): id: Literal[ "http://adlnet.gov/expapi/verbs/terminated" ] = "http://adlnet.gov/expapi/verbs/terminated" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["terminated"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["terminated"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/tincan_vocabulary.py b/src/ralph/models/xapi/concepts/verbs/tincan_vocabulary.py index d32c25a81..1ce482cde 100644 --- a/src/ralph/models/xapi/concepts/verbs/tincan_vocabulary.py +++ b/src/ralph/models/xapi/concepts/verbs/tincan_vocabulary.py @@ -23,7 +23,7 @@ class ViewedVerb(BaseXapiVerb): id: Literal[ "http://id.tincanapi.com/verb/viewed" ] = "http://id.tincanapi.com/verb/viewed" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["viewed"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["viewed"]]] = None class DownloadedVerb(BaseXapiVerb): @@ -37,7 +37,7 @@ class DownloadedVerb(BaseXapiVerb): id: Literal[ "http://id.tincanapi.com/verb/downloaded" ] = "http://id.tincanapi.com/verb/downloaded" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["downloaded"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["downloaded"]]] = None class UnregisteredVerb(BaseXapiVerb): @@ -51,4 +51,4 @@ class UnregisteredVerb(BaseXapiVerb): id: Literal[ "http://id.tincanapi.com/verb/unregistered" ] = "http://id.tincanapi.com/verb/unregistered" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["unregistered"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["unregistered"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/video.py b/src/ralph/models/xapi/concepts/verbs/video.py index d2e83d0b4..4d8129dcd 100644 --- a/src/ralph/models/xapi/concepts/verbs/video.py +++ b/src/ralph/models/xapi/concepts/verbs/video.py @@ -23,7 +23,7 @@ class PlayedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/video/verbs/played" ] = "https://w3id.org/xapi/video/verbs/played" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["played"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["played"]]] = None class PausedVerb(BaseXapiVerb): @@ -37,7 +37,7 @@ class PausedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/video/verbs/paused" ] = "https://w3id.org/xapi/video/verbs/paused" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["paused"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["paused"]]] = None class SeekedVerb(BaseXapiVerb): @@ -51,4 +51,4 @@ class SeekedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/video/verbs/seeked" ] = "https://w3id.org/xapi/video/verbs/seeked" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["seeked"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["seeked"]]] = None diff --git a/src/ralph/models/xapi/concepts/verbs/virtual_classroom.py b/src/ralph/models/xapi/concepts/verbs/virtual_classroom.py index fcd2320a6..b9e618933 100644 --- a/src/ralph/models/xapi/concepts/verbs/virtual_classroom.py +++ b/src/ralph/models/xapi/concepts/verbs/virtual_classroom.py @@ -24,7 +24,7 @@ class MutedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/muted" ] = "https://w3id.org/xapi/virtual-classroom/verbs/muted" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["muted"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["muted"]]] = None class UnmutedVerb(BaseXapiVerb): @@ -39,7 +39,7 @@ class UnmutedVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/unmuted" ] = "https://w3id.org/xapi/virtual-classroom/verbs/unmuted" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["unmuted"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["unmuted"]]] = None class StartedCameraVerb(BaseXapiVerb): @@ -54,7 +54,9 @@ class StartedCameraVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/started-camera" ] = "https://w3id.org/xapi/virtual-classroom/verbs/started-camera" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["started camera"]]] + display: Optional[ + Dict[Literal[LANG_EN_US_DISPLAY], Literal["started camera"]] + ] = None class StoppedCameraVerb(BaseXapiVerb): @@ -69,7 +71,9 @@ class StoppedCameraVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/stopped-camera" ] = "https://w3id.org/xapi/virtual-classroom/verbs/stopped-camera" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["stopped camera"]]] + display: Optional[ + Dict[Literal[LANG_EN_US_DISPLAY], Literal["stopped camera"]] + ] = None class SharedScreenVerb(BaseXapiVerb): @@ -84,7 +88,9 @@ class SharedScreenVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/shared-screen" ] = "https://w3id.org/xapi/virtual-classroom/verbs/shared-screen" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["shared screen"]]] + display: Optional[ + Dict[Literal[LANG_EN_US_DISPLAY], Literal["shared screen"]] + ] = None class UnsharedScreenVerb(BaseXapiVerb): @@ -99,7 +105,9 @@ class UnsharedScreenVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/unshared-screen" ] = "https://w3id.org/xapi/virtual-classroom/verbs/unshared-screen" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["unshared screen"]]] + display: Optional[ + Dict[Literal[LANG_EN_US_DISPLAY], Literal["unshared screen"]] + ] = None class RaisedHandVerb(BaseXapiVerb): @@ -114,7 +122,7 @@ class RaisedHandVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/raised-hand" ] = "https://w3id.org/xapi/virtual-classroom/verbs/raised-hand" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["raised hand"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["raised hand"]]] = None class LoweredHandVerb(BaseXapiVerb): @@ -129,4 +137,4 @@ class LoweredHandVerb(BaseXapiVerb): id: Literal[ "https://w3id.org/xapi/virtual-classroom/verbs/lowered-hand" ] = "https://w3id.org/xapi/virtual-classroom/verbs/lowered-hand" - display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["lowered hand"]]] + display: Optional[Dict[Literal[LANG_EN_US_DISPLAY], Literal["lowered hand"]]] = None diff --git a/src/ralph/models/xapi/config.py b/src/ralph/models/xapi/config.py index 38927c7b6..5afe48b00 100644 --- a/src/ralph/models/xapi/config.py +++ b/src/ralph/models/xapi/config.py @@ -1,19 +1,19 @@ """Base xAPI model configuration.""" -from pydantic import BaseModel, Extra +from pydantic import BaseModel, ConfigDict class BaseModelWithConfig(BaseModel): """Pydantic model for base configuration shared among all models.""" - class Config: # noqa: D106 - extra = Extra.forbid - min_anystr_length = 1 + model_config = ConfigDict( + extra="forbid", str_min_length=1, coerce_numbers_to_str=True + ) class BaseExtensionModelWithConfig(BaseModel): """Pydantic model for extension configuration shared among all models.""" - class Config: # noqa: D106 - extra = Extra.allow - min_anystr_length = 0 + model_config = ConfigDict( + extra="allow", str_min_length=0, coerce_numbers_to_str=True + ) diff --git a/src/ralph/models/xapi/lms/contexts.py b/src/ralph/models/xapi/lms/contexts.py index 3de7a3c2b..c7876f8e8 100644 --- a/src/ralph/models/xapi/lms/contexts.py +++ b/src/ralph/models/xapi/lms/contexts.py @@ -5,7 +5,8 @@ from typing import List, Optional, Union from uuid import UUID -from pydantic import Field, NonNegativeFloat, PositiveInt, condecimal, validator +from pydantic import Field, NonNegativeFloat, PositiveInt, condecimal, field_validator +from typing_extensions import Annotated from ..base.contexts import BaseXapiContext, BaseXapiContextContextActivities from ..base.unnested_objects import BaseXapiActivity @@ -49,7 +50,7 @@ class LMSContextContextActivities(BaseXapiContextContextActivities): LMSProfileActivity, List[Union[LMSProfileActivity, BaseXapiActivity]] ] - @validator("category") + @field_validator("category") @classmethod def check_presence_of_profile_activity_category( cls, @@ -94,9 +95,13 @@ class LMSRegistrationContextExtensions(BaseExtensionModelWithConfig): `guest`, `learner` or `staff`. """ - starting_date: Optional[datetime] = Field(alias=CONTEXT_EXTENSION_STARTING_DATE) - ending_date: Optional[datetime] = Field(alias=CONTEXT_EXTENSION_ENDING_DATE) - role: Optional[str] = Field(alias=CONTEXT_EXTENSION_ROLE) + starting_date: Annotated[ + Optional[datetime], Field(None, alias=CONTEXT_EXTENSION_STARTING_DATE) + ] + ending_date: Annotated[ + Optional[datetime], Field(None, alias=CONTEXT_EXTENSION_ENDING_DATE) + ] + role: Annotated[Optional[str], Field(alias=CONTEXT_EXTENSION_ROLE)] class LMSRegistrationContext(LMSContext): @@ -109,7 +114,7 @@ class LMSRegistrationContext(LMSContext): extensions (dict): see LMSRegistrationContextExtensions. """ - extensions: Optional[LMSRegistrationContextExtensions] + extensions: Optional[LMSRegistrationContextExtensions] = None class LMSCommonContextExtensions(BaseExtensionModelWithConfig): @@ -124,7 +129,9 @@ class LMSCommonContextExtensions(BaseExtensionModelWithConfig): session_id (uuid): ID of the active session. """ - session_id: Optional[UUID] = Field(alias=CONTEXT_EXTENSION_SESSION_ID) + session_id: Annotated[ + Optional[UUID], Field(None, alias=CONTEXT_EXTENSION_SESSION_ID) + ] class LMSCommonContext(LMSContext): @@ -134,7 +141,7 @@ class LMSCommonContext(LMSContext): extensions (dict): See LMSCommonContextExtensions. """ - extensions: Optional[LMSCommonContextExtensions] + extensions: Optional[LMSCommonContextExtensions] = None class LMSDownloadedVideoContextExtensions(LMSCommonContextExtensions): @@ -145,10 +152,11 @@ class LMSDownloadedVideoContextExtensions(LMSCommonContextExtensions): quality (int): Video resolution or quality of the video. """ - length: Optional[condecimal(ge=0, decimal_places=3)] = Field( - alias=CONTEXT_EXTENSION_LENGTH - ) - quality: Optional[PositiveInt] = Field(alias=CONTEXT_EXTENSION_QUALITY) + length: Annotated[ + Optional[condecimal(ge=0, decimal_places=3)], + Field(None, alias=CONTEXT_EXTENSION_LENGTH), + ] + quality: Annotated[Optional[PositiveInt], Field(alias=CONTEXT_EXTENSION_QUALITY)] class LMSDownloadedVideoContext(LMSContext): @@ -158,7 +166,7 @@ class LMSDownloadedVideoContext(LMSContext): extensions (dict): See LMSDownloadedVideoContextExtensions. """ - extensions: Optional[LMSDownloadedVideoContextExtensions] + extensions: Optional[LMSDownloadedVideoContextExtensions] = None class LMSDownloadedAudioContextExtensions(LMSCommonContextExtensions): @@ -168,7 +176,9 @@ class LMSDownloadedAudioContextExtensions(LMSCommonContextExtensions): length (float): Length of the audio. """ - length: Optional[NonNegativeFloat] = Field(alias=CONTEXT_EXTENSION_LENGTH) + length: Annotated[ + Optional[NonNegativeFloat], Field(None, alias=CONTEXT_EXTENSION_LENGTH) + ] class LMSDownloadedAudioContext(LMSContext): @@ -178,4 +188,4 @@ class LMSDownloadedAudioContext(LMSContext): extensions (dict): See LMSDownloadedAudioContextExtensions. """ - extensions: Optional[LMSDownloadedAudioContextExtensions] + extensions: Optional[LMSDownloadedAudioContextExtensions] = None diff --git a/src/ralph/models/xapi/lms/objects.py b/src/ralph/models/xapi/lms/objects.py index 7bc701bff..e17d37e0e 100644 --- a/src/ralph/models/xapi/lms/objects.py +++ b/src/ralph/models/xapi/lms/objects.py @@ -4,6 +4,7 @@ from typing import Optional from pydantic import Field +from typing_extensions import Annotated from ..concepts.activity_types.acrossx_profile import ( WebpageActivity, @@ -33,9 +34,10 @@ class LMSPageObjectDefinitionExtensions(BaseExtensionModelWithConfig): `course_list`, `user_space` value. """ - type: Optional[Literal["course", "course_list", "user_space"]] = Field( - alias=ACTIVITY_EXTENSIONS_TYPE - ) + type: Annotated[ + Optional[Literal["course", "course_list", "user_space"]], + Field(None, alias=ACTIVITY_EXTENSIONS_TYPE), + ] class LMSPageObjectDefinition(WebpageActivityDefinition): @@ -45,7 +47,7 @@ class LMSPageObjectDefinition(WebpageActivityDefinition): extensions (dict): see LMSPageObjectDefinitionExtensions. """ - extensions: Optional[LMSPageObjectDefinitionExtensions] + extensions: Optional[LMSPageObjectDefinitionExtensions] = None class LMSPageObject(WebpageActivity): @@ -68,7 +70,7 @@ class LMSFileObjectDefinitionExtensions(BaseExtensionModelWithConfig): type (str): Characterisation of the MIME type of the file. """ - type: str = Field(alias=ACTIVITY_EXTENSIONS_TYPE) + type: Annotated[str, Field(alias=ACTIVITY_EXTENSIONS_TYPE)] class LMSFileObjectDefinition(FileActivityDefinition): @@ -78,7 +80,7 @@ class LMSFileObjectDefinition(FileActivityDefinition): extensions (dict): see LMSFileObjectDefinitionExtensions. """ - extensions: Optional[LMSFileObjectDefinitionExtensions] + extensions: Optional[LMSFileObjectDefinitionExtensions] = None class LMSFileObject(FileActivity): diff --git a/src/ralph/models/xapi/video/contexts.py b/src/ralph/models/xapi/video/contexts.py index 4cb6629eb..24ff915d5 100644 --- a/src/ralph/models/xapi/video/contexts.py +++ b/src/ralph/models/xapi/video/contexts.py @@ -4,7 +4,8 @@ from typing import List, Optional, Union from uuid import UUID -from pydantic import Field, NonNegativeFloat, validator +from pydantic import Field, NonNegativeFloat, field_validator +from typing_extensions import Annotated from ..base.contexts import BaseXapiContext, BaseXapiContextContextActivities from ..base.unnested_objects import BaseXapiActivity @@ -51,7 +52,7 @@ class VideoContextContextActivities(BaseXapiContextContextActivities): VideoProfileActivity, List[Union[VideoProfileActivity, BaseXapiActivity]] ] - @validator("category") + @field_validator("category") @classmethod def check_presence_of_profile_activity_category( cls, @@ -90,7 +91,7 @@ class VideoContextExtensions(BaseExtensionModelWithConfig): session (uuid): Consists of the ID of the active session. """ - session_id: Optional[UUID] = Field(alias=CONTEXT_EXTENSION_SESSION_ID) + session_id: Annotated[Optional[UUID], Field(alias=CONTEXT_EXTENSION_SESSION_ID)] class VideoInitializedContextExtensions(VideoContextExtensions): @@ -115,20 +116,28 @@ class VideoInitializedContextExtensions(VideoContextExtensions): consumed to trigger a completion. """ - length: NonNegativeFloat = Field(alias=CONTEXT_EXTENSION_LENGTH) - ccSubtitleEnabled: Optional[bool] = Field(alias=CONTEXT_EXTENSION_CC_ENABLED) - ccSubtitleLang: Optional[str] = Field(alias=CONTEXT_EXTENSION_CC_SUBTITLE_LANG) - fullScreen: Optional[bool] = Field(alias=CONTEXT_EXTENSION_FULL_SCREEN) - screenSize: Optional[str] = Field(alias=CONTEXT_EXTENSION_SCREEN_SIZE) - videoPlaybackSize: Optional[str] = Field( - alias=CONTEXT_EXTENSION_VIDEO_PLAYBACK_SIZE - ) - speed: Optional[str] = Field(alias=CONTEXT_EXTENSION_SPEED) - userAgent: Optional[str] = Field(alias=CONTEXT_EXTENSION_USER_AGENT) - volume: Optional[int] = Field(alias=CONTEXT_EXTENSION_VOLUME) - completionThreshold: Optional[float] = Field( - alias=CONTEXT_EXTENSION_COMPLETION_THRESHOLD - ) + length: Annotated[NonNegativeFloat, Field(alias=CONTEXT_EXTENSION_LENGTH)] + ccSubtitleEnabled: Annotated[ + Optional[bool], Field(None, alias=CONTEXT_EXTENSION_CC_ENABLED) + ] + ccSubtitleLang: Annotated[ + Optional[str], Field(None, alias=CONTEXT_EXTENSION_CC_SUBTITLE_LANG) + ] + fullScreen: Annotated[ + Optional[bool], Field(None, alias=CONTEXT_EXTENSION_FULL_SCREEN) + ] + screenSize: Annotated[ + Optional[str], Field(None, alias=CONTEXT_EXTENSION_SCREEN_SIZE) + ] + videoPlaybackSize: Annotated[ + Optional[str], Field(None, alias=CONTEXT_EXTENSION_VIDEO_PLAYBACK_SIZE) + ] + speed: Annotated[Optional[str], Field(None, alias=CONTEXT_EXTENSION_SPEED)] + userAgent: Annotated[Optional[str], Field(None, alias=CONTEXT_EXTENSION_USER_AGENT)] + volume: Annotated[Optional[int], Field(None, alias=CONTEXT_EXTENSION_VOLUME)] + completionThreshold: Annotated[ + Optional[float], Field(None, alias=CONTEXT_EXTENSION_COMPLETION_THRESHOLD) + ] class VideoBrowsingContextExtensions(VideoContextExtensions): @@ -142,10 +151,10 @@ class VideoBrowsingContextExtensions(VideoContextExtensions): length (float): Consists of the length of the video. """ - length: NonNegativeFloat = Field(alias=CONTEXT_EXTENSION_LENGTH) - completionThreshold: Optional[float] = Field( - alias=CONTEXT_EXTENSION_COMPLETION_THRESHOLD - ) + length: Annotated[NonNegativeFloat, Field(alias=CONTEXT_EXTENSION_LENGTH)] + completionThreshold: Annotated[ + Optional[float], Field(None, alias=CONTEXT_EXTENSION_COMPLETION_THRESHOLD) + ] class VideoEnableClosedCaptioningContextExtensions(VideoContextExtensions): @@ -156,7 +165,7 @@ class VideoEnableClosedCaptioningContextExtensions(VideoContextExtensions): captioning. """ - ccSubtitleLanguage: str = Field(alias=CONTEXT_EXTENSION_CC_SUBTITLE_LANG) + ccSubtitleLanguage: Annotated[str, Field(alias=CONTEXT_EXTENSION_CC_SUBTITLE_LANG)] class VideoVolumeChangeInteractionContextExtensions(VideoContextExtensions): @@ -167,7 +176,7 @@ class VideoVolumeChangeInteractionContextExtensions(VideoContextExtensions): volume (int): Consists of the volume of the video. """ # noqa: D205 - volume: int = Field(alias=CONTEXT_EXTENSION_VOLUME) + volume: Annotated[int, Field(alias=CONTEXT_EXTENSION_VOLUME)] class VideoScreenChangeInteractionContextExtensions(VideoContextExtensions): @@ -181,9 +190,11 @@ class VideoScreenChangeInteractionContextExtensions(VideoContextExtensions): viewed by the user. """ # noqa: D205 - fullScreen: bool = Field(alias=CONTEXT_EXTENSION_FULL_SCREEN) - screenSize: str = Field(alias=CONTEXT_EXTENSION_SCREEN_SIZE) - videoPlaybackSize: str = Field(alias=CONTEXT_EXTENSION_VIDEO_PLAYBACK_SIZE) + fullScreen: Annotated[bool, Field(alias=CONTEXT_EXTENSION_FULL_SCREEN)] + screenSize: Annotated[str, Field(alias=CONTEXT_EXTENSION_SCREEN_SIZE)] + videoPlaybackSize: Annotated[ + str, Field(alias=CONTEXT_EXTENSION_VIDEO_PLAYBACK_SIZE) + ] class VideoInitializedContext(BaseVideoContext): @@ -203,7 +214,7 @@ class VideoPlayedContext(BaseVideoContext): extensions (dict): See VideoContextExtensions. """ - extensions: Optional[VideoContextExtensions] + extensions: Optional[VideoContextExtensions] = None class VideoPausedContext(BaseVideoContext): @@ -223,7 +234,7 @@ class VideoSeekedContext(BaseVideoContext): extensions (dict): See VideoContextExtensions. """ - extensions: Optional[VideoContextExtensions] + extensions: Optional[VideoContextExtensions] = None class VideoCompletedContext(BaseVideoContext): diff --git a/src/ralph/models/xapi/video/results.py b/src/ralph/models/xapi/video/results.py index 7c515ad53..e7a1d5406 100644 --- a/src/ralph/models/xapi/video/results.py +++ b/src/ralph/models/xapi/video/results.py @@ -5,6 +5,7 @@ from typing import Optional from pydantic import Field, NonNegativeFloat +from typing_extensions import Annotated from ..base.results import BaseXapiResult from ..concepts.constants.video import ( @@ -33,8 +34,10 @@ class VideoResultExtensions(BaseExtensionModelWithConfig): time (float): Consists of the video time code when the event was emitted. """ - time: NonNegativeFloat = Field(alias=RESULT_EXTENSION_TIME) - playedSegments: Optional[str] = Field(alias=CONTEXT_EXTENSION_PLAYED_SEGMENTS) + time: Annotated[NonNegativeFloat, Field(alias=RESULT_EXTENSION_TIME)] + playedSegments: Annotated[ + Optional[str], Field(None, alias=CONTEXT_EXTENSION_PLAYED_SEGMENTS) + ] class VideoPausedResultExtensions(VideoResultExtensions): @@ -44,7 +47,9 @@ class VideoPausedResultExtensions(VideoResultExtensions): progress (float): Consists of the ratio of media consumed by the actor. """ - progress: Optional[NonNegativeFloat] = Field(alias=RESULT_EXTENSION_PROGRESS) + progress: Annotated[ + Optional[NonNegativeFloat], Field(None, alias=RESULT_EXTENSION_PROGRESS) + ] class VideoSeekedResultExtensions(BaseExtensionModelWithConfig): @@ -57,8 +62,8 @@ class VideoSeekedResultExtensions(BaseExtensionModelWithConfig): object during a seek operation. """ - timeFrom: NonNegativeFloat = Field(alias=RESULT_EXTENSION_TIME_FROM) - timeTo: NonNegativeFloat = Field(alias=RESULT_EXTENSION_TIME_TO) + timeFrom: Annotated[NonNegativeFloat, Field(alias=RESULT_EXTENSION_TIME_FROM)] + timeTo: Annotated[NonNegativeFloat, Field(alias=RESULT_EXTENSION_TIME_TO)] class VideoCompletedResultExtensions(VideoResultExtensions): @@ -68,7 +73,7 @@ class VideoCompletedResultExtensions(VideoResultExtensions): progress (float): Consists of the percentage of media consumed by the actor. """ - progress: NonNegativeFloat = Field(alias=RESULT_EXTENSION_PROGRESS) + progress: Annotated[NonNegativeFloat, Field(alias=RESULT_EXTENSION_PROGRESS)] class VideoTerminatedResultExtensions(VideoResultExtensions): @@ -78,7 +83,7 @@ class VideoTerminatedResultExtensions(VideoResultExtensions): progress (float): Consists of the percentage of media consumed by the actor. """ - progress: NonNegativeFloat = Field(alias=RESULT_EXTENSION_PROGRESS) + progress: Annotated[NonNegativeFloat, Field(alias=RESULT_EXTENSION_PROGRESS)] class VideoEnableClosedCaptioningResultExtensions(VideoResultExtensions): @@ -88,7 +93,7 @@ class VideoEnableClosedCaptioningResultExtensions(VideoResultExtensions): ccEnabled (bool): Indicates whether subtitles are enabled. """ - ccEnabled: bool = Field(alias=CONTEXT_EXTENSION_CC_ENABLED) + ccEnabled: Annotated[bool, Field(alias=CONTEXT_EXTENSION_CC_ENABLED)] class VideoPlayedResult(BaseXapiResult): @@ -132,8 +137,8 @@ class VideoCompletedResult(BaseXapiResult): """ extensions: VideoCompletedResultExtensions - completion: Optional[Literal[True]] - duration: Optional[timedelta] + completion: Optional[Literal[True]] = None + duration: Optional[timedelta] = None class VideoTerminatedResult(BaseXapiResult): diff --git a/src/ralph/models/xapi/virtual_classroom/contexts.py b/src/ralph/models/xapi/virtual_classroom/contexts.py index f6ebf2c08..a14215c56 100644 --- a/src/ralph/models/xapi/virtual_classroom/contexts.py +++ b/src/ralph/models/xapi/virtual_classroom/contexts.py @@ -5,7 +5,8 @@ from typing import List, Optional, Union from uuid import UUID -from pydantic import Field, validator +from pydantic import Field, field_validator +from typing_extensions import Annotated from ..base.contexts import BaseXapiContext, BaseXapiContextContextActivities from ..base.unnested_objects import BaseXapiActivity @@ -45,7 +46,7 @@ class VirtualClassroomContextContextActivities(BaseXapiContextContextActivities) List[Union[VirtualClassroomProfileActivity, BaseXapiActivity]], ] - @validator("category") + @field_validator("category") @classmethod def check_presence_of_profile_activity_category( cls, @@ -76,7 +77,7 @@ class VirtualClassroomContextExtensions(BaseExtensionModelWithConfig): session_id (str): Consists of the ID of the active session. """ - session_id: str = Field(alias=CONTEXT_EXTENSION_SESSION_ID, default="") + session_id: Annotated[str, Field(alias=CONTEXT_EXTENSION_SESSION_ID, default="")] class VirtualClassroomContext(BaseXapiContext): @@ -103,9 +104,9 @@ class VirtualClassroomInitializedContextExtensions(VirtualClassroomContextExtens virtual classroom. """ - planned_duration: Optional[datetime] = Field( - alias=CONTEXT_EXTENSION_PLANNED_DURATION - ) + planned_duration: Annotated[ + Optional[datetime], Field(alias=CONTEXT_EXTENSION_PLANNED_DURATION) + ] class VirtualClassroomInitializedContext(VirtualClassroomContext): @@ -129,9 +130,9 @@ class VirtualClassroomJoinedContextExtensions(VirtualClassroomContextExtensions) virtual classroom. """ - planned_duration: Optional[datetime] = Field( - alias=CONTEXT_EXTENSION_PLANNED_DURATION - ) + planned_duration: Annotated[ + Optional[datetime], Field(alias=CONTEXT_EXTENSION_PLANNED_DURATION) + ] class VirtualClassroomJoinedContext(VirtualClassroomContext): @@ -155,9 +156,9 @@ class VirtualClassroomTerminatedContextExtensions(VirtualClassroomContextExtensi virtual classroom. """ - planned_duration: Optional[datetime] = Field( - alias=CONTEXT_EXTENSION_PLANNED_DURATION - ) + planned_duration: Annotated[ + Optional[datetime], Field(alias=CONTEXT_EXTENSION_PLANNED_DURATION) + ] class VirtualClassroomTerminatedContext(VirtualClassroomContext): diff --git a/src/ralph/models/xapi/virtual_classroom/results.py b/src/ralph/models/xapi/virtual_classroom/results.py index 0bde87312..d1a438bf7 100644 --- a/src/ralph/models/xapi/virtual_classroom/results.py +++ b/src/ralph/models/xapi/virtual_classroom/results.py @@ -1,6 +1,6 @@ """Virtual classroom xAPI events result fields definitions.""" -from pydantic import StrictStr +from ralph.conf import NonEmptyStrictStr from ..base.results import BaseXapiResult @@ -12,4 +12,4 @@ class VirtualClassroomAnsweredPollResult(BaseXapiResult): response (str): Consists of the response for the given Activity. """ - response: StrictStr # = StrictStr() + response: NonEmptyStrictStr # = StrictStr() diff --git a/src/ralph/models/xapi/virtual_classroom/statements.py b/src/ralph/models/xapi/virtual_classroom/statements.py index 7494b661b..d23e9bb40 100644 --- a/src/ralph/models/xapi/virtual_classroom/statements.py +++ b/src/ralph/models/xapi/virtual_classroom/statements.py @@ -371,7 +371,6 @@ class VirtualClassroomAnsweredPoll(BaseVirtualClassroomStatement): object (dict): See CMIInteractionActivity. context (dict): See VirtualClassroomAnsweredPollContext. result (dict): See AnsweredPollResult. - result (dict): See AnsweredPollResult. timestamp (datetime): Consists of the timestamp of when the event occurred. result (dict): See AnsweredPollResult. timestamp (datetime): Consists of the timestamp of when the event occurred. diff --git a/tests/api/auth/test_basic.py b/tests/api/auth/test_basic.py index 7d69d89f2..b62098a1e 100644 --- a/tests/api/auth/test_basic.py +++ b/tests/api/auth/test_basic.py @@ -35,7 +35,7 @@ def test_api_auth_basic_model_serveruserscredentials(): """Test api.auth ServerUsersCredentials model.""" users = ServerUsersCredentials( - __root__=[ + root=[ UserCredentials( username="johndoe", hash="notrealhash", @@ -50,7 +50,7 @@ def test_api_auth_basic_model_serveruserscredentials(): ), ] ) - other_users = ServerUsersCredentials.parse_obj( + other_users = ServerUsersCredentials.model_validate( [ UserCredentials( username="janedoe", @@ -82,7 +82,7 @@ def test_api_auth_basic_model_serveruserscredentials(): ValueError, match="You cannot create multiple credentials with the same username", ): - users += ServerUsersCredentials.parse_obj( + users += ServerUsersCredentials.model_validate( [ UserCredentials( username="foo", diff --git a/tests/api/test_forwarding.py b/tests/api/test_forwarding.py index 7f7db3ec2..2464995ae 100644 --- a/tests/api/test_forwarding.py +++ b/tests/api/test_forwarding.py @@ -5,47 +5,49 @@ import pytest from httpx import RequestError -from hypothesis import HealthCheck -from hypothesis import settings as hypothesis_settings -from hypothesis import strategies as st from pydantic import ValidationError from ralph.api.forwarding import forward_xapi_statements, get_active_xapi_forwardings from ralph.conf import Settings, XapiForwardingConfigurationSettings -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance -@hypothesis_settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) -@custom_given(XapiForwardingConfigurationSettings) -def test_api_forwarding_with_valid_configuration(monkeypatch, forwarding_settings): +def test_api_forwarding_with_valid_configuration( + monkeypatch, +): """Test the settings, given a valid forwarding configuration, should not raise an exception. """ + forwarding_settings = mock_instance(XapiForwardingConfigurationSettings) + monkeypatch.delenv("RALPH_XAPI_FORWARDINGS", raising=False) settings = Settings() assert settings.XAPI_FORWARDINGS == [] - monkeypatch.setenv("RALPH_XAPI_FORWARDINGS", f"[{forwarding_settings.json()}]") + monkeypatch.setenv( + "RALPH_XAPI_FORWARDINGS", f"[{forwarding_settings.model_dump_json()}]" + ) settings = Settings() assert len(settings.XAPI_FORWARDINGS) == 1 assert settings.XAPI_FORWARDINGS[0] == forwarding_settings -@hypothesis_settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) @pytest.mark.parametrize( "missing_key", ["url", "is_active", "basic_username", "basic_password", "max_retries", "timeout"], ) -@custom_given(XapiForwardingConfigurationSettings) -def test_api_forwarding_configuration_with_missing_field(missing_key, forwarding): +def test_api_forwarding_configuration_with_missing_field(missing_key): """Test the forwarding configuration, given a missing field, should raise a validation exception. """ - forwarding_dict = json.loads(forwarding.json()) + + forwarding = mock_instance(XapiForwardingConfigurationSettings) + + forwarding_dict = json.loads(forwarding.model_dump_json()) del forwarding_dict[missing_key] - with pytest.raises(ValidationError, match=f"{missing_key}\n field required"): + with pytest.raises(ValidationError, match=f"{missing_key}\n Field required"): XapiForwardingConfigurationSettings(**forwarding_dict) @@ -75,20 +77,23 @@ def test_api_forwarding_get_active_xapi_forwardings_with_empty_forwardings( assert caplog.record_tuples[0][2] == expected_log -@hypothesis_settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) -@custom_given( - custom_builds(XapiForwardingConfigurationSettings, is_active=st.just(True)), - custom_builds(XapiForwardingConfigurationSettings, is_active=st.just(False)), -) def test_api_forwarding_get_active_xapi_forwardings_with_inactive_forwardings( - monkeypatch, caplog, active_forwarding, inactive_forwarding + monkeypatch, caplog ): """Test that the get_active_xapi_forwardings function, given a forwarding configuration containing inactive forwardings, should log which forwarding configurations are inactive and return a list containing only active forwardings. """ - active_forwarding_json = active_forwarding.json() - inactive_forwarding_json = inactive_forwarding.json() + + active_forwarding = mock_instance( + XapiForwardingConfigurationSettings, is_active=True + ) + inactive_forwarding = mock_instance( + XapiForwardingConfigurationSettings, is_active=False + ) + + active_forwarding_json = active_forwarding.model_dump_json() + inactive_forwarding_json = inactive_forwarding.model_dump_json() # One inactive forwarding and no active forwarding. monkeypatch.setenv("RALPH_XAPI_FORWARDINGS", f"[{inactive_forwarding_json}]") @@ -124,24 +129,18 @@ def test_api_forwarding_get_active_xapi_forwardings_with_inactive_forwardings( @pytest.mark.anyio -@hypothesis_settings( - deadline=None, suppress_health_check=(HealthCheck.function_scoped_fixture,) -) @pytest.mark.parametrize("statements", [[{}, {"id": 1}]]) -@custom_given( - custom_builds( - XapiForwardingConfigurationSettings, - max_retries=st.just(1), - is_active=st.just(True), - ) -) async def test_api_forwarding_forward_xapi_statements_with_successful_request( - monkeypatch, caplog, statements, forwarding + monkeypatch, caplog, statements ): """Test the forward_xapi_statements function should log the forwarded statements count if the request was successful. """ + forwarding = mock_instance( + XapiForwardingConfigurationSettings, max_retries=1, is_active=True + ) + class MockSuccessfulResponse: """Dummy Successful Response.""" @@ -154,7 +153,7 @@ async def post_success(*args, **kwargs): return MockSuccessfulResponse() monkeypatch.setattr("ralph.api.forwarding.AsyncClient.post", post_success) - monkeypatch.setenv("RALPH_XAPI_FORWARDINGS", f"[{forwarding.json()}]") + monkeypatch.setenv("RALPH_XAPI_FORWARDINGS", f"[{forwarding.model_dump_json()}]") monkeypatch.setattr("ralph.api.forwarding.settings", Settings()) get_active_xapi_forwardings.cache_clear() @@ -163,7 +162,7 @@ async def post_success(*args, **kwargs): await forward_xapi_statements(statements, method="post") assert [ - f"Forwarded {len(statements)} statements to {forwarding.url} with success." + f"Forwarded {len(statements)} statements to {str(forwarding.url)} with success." ] == [ message for source, _, message in caplog.record_tuples @@ -172,22 +171,20 @@ async def post_success(*args, **kwargs): @pytest.mark.anyio -@hypothesis_settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) @pytest.mark.parametrize("statements", [[{}, {"id": 1}]]) -@custom_given( - custom_builds( - XapiForwardingConfigurationSettings, - max_retries=st.just(3), - is_active=st.just(True), - ) -) async def test_api_forwarding_forward_xapi_statements_with_unsuccessful_request( - monkeypatch, caplog, statements, forwarding + monkeypatch, caplog, statements ): """Test the forward_xapi_statements function should log the error if the request was successful. """ + forwarding = mock_instance( + XapiForwardingConfigurationSettings, + max_retries=3, + is_active=True, + ) + class MockUnsuccessfulResponse: """Dummy Failing Response.""" @@ -201,7 +198,7 @@ async def post_fail(*args, **kwargs): return MockUnsuccessfulResponse() monkeypatch.setattr("ralph.api.forwarding.AsyncClient.post", post_fail) - monkeypatch.setenv("RALPH_XAPI_FORWARDINGS", f"[{forwarding.json()}]") + monkeypatch.setenv("RALPH_XAPI_FORWARDINGS", f"[{forwarding.model_dump_json()}]") monkeypatch.setattr("ralph.api.forwarding.settings", Settings()) get_active_xapi_forwardings.cache_clear() diff --git a/tests/api/test_statements_get.py b/tests/api/test_statements_get.py index 84efdc2ec..296d69d18 100644 --- a/tests/api/test_statements_get.py +++ b/tests/api/test_statements_get.py @@ -138,18 +138,18 @@ async def test_api_statements_get_mine( if ifi == "account_same_home_page": agent_1 = mock_agent("account", 1, home_page_id=1) agent_1_bis = mock_agent( - "account", 1, home_page_id=1, name="name", use_object_type=False + "account", 1, home_page_id=1, name="myname", use_object_type=False ) agent_2 = mock_agent("account", 2, home_page_id=1) elif ifi == "account_different_home_page": agent_1 = mock_agent("account", 1, home_page_id=1) agent_1_bis = mock_agent( - "account", 1, home_page_id=1, name="name", use_object_type=False + "account", 1, home_page_id=1, name="myname", use_object_type=False ) agent_2 = mock_agent("account", 1, home_page_id=2) else: agent_1 = mock_agent(ifi, 1) - agent_1_bis = mock_agent(ifi, 1, name="name", use_object_type=False) + agent_1_bis = mock_agent(ifi, 1, name="myname", use_object_type=False) agent_2 = mock_agent(ifi, 2) username_1 = "jane" @@ -451,7 +451,10 @@ async def test_api_statements_get_by_activity( ) assert response.status_code == 422 - assert response.json()["detail"][0]["msg"] == "'INVALID_IRI' is not a valid 'IRI'." + assert ( + response.json()["detail"][0]["msg"] + == "Value error, 'INVALID_IRI' is not a valid 'IRI'." + ) @pytest.mark.anyio @@ -784,10 +787,9 @@ async def test_api_statements_get_invalid_query_parameters( (["all/read"], True), (["statements/read/mine"], True), (["statements/read"], True), - (["profile/write", "statements/read", "all/write"], True), + (["profile/write", "statements/read", "profile/read"], True), (["statements/write"], False), (["profile/read"], False), - (["all/write"], False), ([], False), ], ) diff --git a/tests/backends/data/test_async_lrs.py b/tests/backends/data/test_async_lrs.py index b6e7f85f8..e1b51091e 100644 --- a/tests/backends/data/test_async_lrs.py +++ b/tests/backends/data/test_async_lrs.py @@ -10,7 +10,7 @@ import httpx import pytest from httpx import HTTPStatusError, RequestError -from pydantic import AnyHttpUrl, parse_obj_as +from pydantic import AnyHttpUrl, AnyUrl, parse_obj_as from pytest_httpx import HTTPXMock from ralph.backends.data.async_lrs import AsyncLRSDataBackend @@ -81,7 +81,8 @@ def test_backends_data_async_lrs_instantiation_with_settings(lrs_backend): assert AsyncLRSDataBackend.settings_class == LRSDataBackendSettings backend = AsyncLRSDataBackend(settings) assert backend.query_class == LRSStatementsQuery - assert isinstance(backend.base_url, AnyHttpUrl) + assert isinstance(backend.base_url, AnyUrl) + assert backend.base_url.scheme.lower() in ["http", "https"] assert backend.auth == ("user", "pass") assert backend.settings.HEADERS.CONTENT_TYPE == "application/json" assert backend.settings.HEADERS.X_EXPERIENCE_API_VERSION == "1.0.3" diff --git a/tests/backends/data/test_async_mongo.py b/tests/backends/data/test_async_mongo.py index aa234196b..9d82f11e0 100644 --- a/tests/backends/data/test_async_mongo.py +++ b/tests/backends/data/test_async_mongo.py @@ -50,7 +50,7 @@ async def test_backends_data_async_mongo_default_instantiation(monkeypatch, fs): assert isinstance(backend.client, AsyncIOMotorClient) assert backend.database.name == "statements" assert backend.collection.name == "marsha" - assert backend.settings.CONNECTION_URI == "mongodb://localhost:27017/" + assert str(backend.settings.CONNECTION_URI) == "mongodb://localhost:27017/" assert backend.settings.CLIENT_OPTIONS == MongoClientOptions() assert backend.settings.LOCALE_ENCODING == "utf8" assert backend.settings.READ_CHUNK_SIZE == 500 @@ -71,7 +71,7 @@ async def test_backends_data_async_mongo_instantiation_with_settings( backend = async_mongo_backend(default_collection="foo") assert backend.database.name == MONGO_TEST_DATABASE assert backend.collection.name == "foo" - assert backend.settings.CONNECTION_URI == MONGO_TEST_CONNECTION_URI + assert str(backend.settings.CONNECTION_URI) == MONGO_TEST_CONNECTION_URI assert backend.settings.CLIENT_OPTIONS == MongoClientOptions() assert backend.settings.LOCALE_ENCODING == "utf8" assert backend.settings.READ_CHUNK_SIZE == 500 diff --git a/tests/backends/data/test_async_ws.py b/tests/backends/data/test_async_ws.py index a90574706..632241bc6 100644 --- a/tests/backends/data/test_async_ws.py +++ b/tests/backends/data/test_async_ws.py @@ -6,6 +6,7 @@ import pytest import websockets +from pydantic import AnyUrl, parse_obj_as from ralph.backends.data.async_ws import AsyncWSDataBackend, WSDataBackendSettings from ralph.backends.data.base import DataBackendStatus @@ -33,7 +34,8 @@ def test_backends_data_async_ws_default_instantiation(caplog, monkeypatch, fs): msg = ( "Failed to instantiate default async data backend settings: " "1 validation error for WSDataBackendSettings\nURI\n " - "field required (type=value_error.missing)" + "Field required [type=missing, input_value={}, input_type=dict]\n " + "For further information visit https://errors.pydantic.dev/2.5/v/missing" ) with pytest.raises(BackendParameterException, match=re.escape(msg)): with caplog.at_level(logging.ERROR): @@ -47,10 +49,9 @@ def test_backends_data_async_ws_instantiation_with_settings(monkeypatch): uri = f"ws://{WS_TEST_HOST}:{WS_TEST_PORT}" settings = WSDataBackendSettings(URI=uri) backend = AsyncWSDataBackend(settings) - assert backend.settings.URI == uri + assert backend.settings.URI == parse_obj_as(AnyUrl, uri) assert backend.settings.LOCALE_ENCODING == "utf8" assert backend.settings.READ_CHUNK_SIZE == 500 - assert backend.settings.URI == uri assert backend.settings.WRITE_CHUNK_SIZE == 500 # Test overriding default values with environment variables. @@ -58,7 +59,7 @@ def test_backends_data_async_ws_instantiation_with_settings(monkeypatch): monkeypatch.setenv("RALPH_BACKENDS__DATA__WS__URI", "ws://foo") backend = AsyncWSDataBackend() assert backend.settings.READ_CHUNK_SIZE == 1 - assert backend.settings.URI == "ws://foo" + assert backend.settings.URI == parse_obj_as(AnyUrl, "ws://foo") @pytest.mark.anyio @@ -74,11 +75,11 @@ async def test_backends_data_async_ws_status_with_error_status(ws, events, caplo assert ( "ralph.backends.data.async_ws", logging.ERROR, - "Failed open websocket connection for ws://127.0.0.1:1: " + "Failed open websocket connection for ws://127.0.0.1:1/: " "[Errno 111] Connect call failed ('127.0.0.1', 1)", ) in caplog.record_tuples - uri = f"ws://{WS_TEST_HOST}:{WS_TEST_PORT}" + uri = parse_obj_as(AnyUrl, f"ws://{WS_TEST_HOST}:{WS_TEST_PORT}") settings = WSDataBackendSettings(URI=uri) backend = AsyncWSDataBackend(settings) assert [_ async for _ in backend.read(raw_output=False)] == events @@ -88,7 +89,7 @@ async def test_backends_data_async_ws_status_with_error_status(ws, events, caplo assert ( "ralph.backends.data.async_ws", logging.ERROR, - f"Failed to Ping {uri}: received 1000 (OK); then sent 1000 (OK)", + f"Failed to Ping {str(uri)}: received 1000 (OK); then sent 1000 (OK)", ) in caplog.record_tuples @@ -178,7 +179,7 @@ async def get_mock_client(): monkeypatch.setattr(backend, "client", get_mock_client) msg = ( - "Failed to receive message from websocket ws://foo: " + "Failed to receive message from websocket ws://foo/: " "no close frame received or sent" ) with pytest.raises(BackendException, match=msg): @@ -221,7 +222,7 @@ def close(self): monkeypatch.setattr(backend, "_client", MockClient()) msg = ( - "Failed to close websocket connection for ws://foo: " + "Failed to close websocket connection for ws://foo/: " "no close frame received or sent" ) with pytest.raises(BackendException, match=re.escape(msg)): diff --git a/tests/backends/data/test_base.py b/tests/backends/data/test_base.py index 7082d18ef..09d498f90 100644 --- a/tests/backends/data/test_base.py +++ b/tests/backends/data/test_base.py @@ -50,13 +50,13 @@ def close(self): msg = ( "Failed to instantiate default data backend settings: " "1 validation error for MockBaseDataBackendSettigns\nFOO\n " - "field required (type=value_error.missing)" ) - with pytest.raises(BackendParameterException, match=re.escape(msg)): + with pytest.raises(BackendParameterException, match=msg): with caplog.at_level(logging.ERROR): MockBaseDataBackend() - - assert ("ralph.backends.data.base", logging.ERROR, msg) in caplog.record_tuples + assert "ralph.backends.data.base" == caplog.record_tuples[0][0] + assert logging.ERROR == caplog.record_tuples[0][1] + assert msg in caplog.record_tuples[0][2] def test_backends_data_base_async_instantiation(caplog): @@ -87,15 +87,16 @@ async def close(self): # Given missing required settings, the `AsyncDataBackend` should raise a # `BackendParameterException` on instantiation. msg = ( - "Failed to instantiate default async data backend settings: " - "1 validation error for MockBaseAsyncDataBackendSettigns\nFOO\n " - "field required (type=value_error.missing)" + "Failed to instantiate default async data backend settings: 1 validation " + "error for MockBaseAsyncDataBackendSettigns\nFOO\n Field required " ) - with pytest.raises(BackendParameterException, match=re.escape(msg)): + with pytest.raises(BackendParameterException, match=msg): with caplog.at_level(logging.ERROR): MockBaseAsyncDataBackend() - assert ("ralph.backends.data.base", logging.ERROR, msg) in caplog.record_tuples + assert "ralph.backends.data.base" == caplog.record_tuples[0][0] + assert logging.ERROR == caplog.record_tuples[0][1] + assert msg in caplog.record_tuples[0][2] def test_backends_data_base_validate_backend_query(caplog): @@ -113,9 +114,9 @@ class NonDefaultQuery(BaseQuery): required_value: int msg = ( - "Invalid NonDefaultQuery default query: " - "[{'loc': ('required_value',), 'msg': 'field required', " - "'type': 'value_error.missing'}]" + "Invalid NonDefaultQuery default query: [{'type': 'missing', 'loc': " + "('required_value',), 'msg': 'Field required', 'input': {}, 'url': " + "'https://errors.pydantic.dev/2.5/v/missing'}]" ) with pytest.raises(BackendParameterException, match=re.escape(msg)): validate_backend_query(None, NonDefaultQuery) diff --git a/tests/backends/data/test_fs.py b/tests/backends/data/test_fs.py index fb1bf2f70..01d453e51 100644 --- a/tests/backends/data/test_fs.py +++ b/tests/backends/data/test_fs.py @@ -5,6 +5,7 @@ import re from collections.abc import Iterable from operator import itemgetter +from pathlib import Path from uuid import uuid4 import pytest @@ -41,7 +42,7 @@ def test_backends_data_fs_default_instantiation(monkeypatch, fs): assert backend.settings.WRITE_CHUNK_SIZE == 4096 # Test overriding default values with environment variables. - monkeypatch.setenv("RALPH_BACKENDS__DATA__FS__READ_CHUNK_SIZE", 1) + monkeypatch.setenv("RALPH_BACKENDS__DATA__FS__READ_CHUNK_SIZE", "1") backend = FSDataBackend() assert backend.settings.READ_CHUNK_SIZE == 1 @@ -52,7 +53,7 @@ def test_backends_data_fs_instantiation_with_settings(fs): deep_path = "deep/directories/path" assert not os.path.exists(deep_path) settings = FSDataBackend.settings_class( - DEFAULT_DIRECTORY_PATH=deep_path, + DEFAULT_DIRECTORY_PATH=Path(deep_path), DEFAULT_QUERY_STRING="foo.txt", LOCALE_ENCODING="utf-16", READ_CHUNK_SIZE=1, @@ -703,7 +704,7 @@ def test_backends_data_fs_read_with_query(fs_backend, fs): default_path = "foo/" fs.create_file(default_path + "file_3.txt", contents=valid_json) fs.create_file(default_path + "file_4.txt", contents=valid_json) - fs.create_file(default_path + "/bar/file_5.txt", contents=invalid_json) + fs.create_file(default_path.rstrip("/") + "/bar/file_5.txt", contents=invalid_json) backend = fs_backend() diff --git a/tests/backends/data/test_mongo.py b/tests/backends/data/test_mongo.py index 5b054982c..95909a9f9 100644 --- a/tests/backends/data/test_mongo.py +++ b/tests/backends/data/test_mongo.py @@ -48,7 +48,7 @@ def test_backends_data_mongo_default_instantiation(monkeypatch, fs): assert isinstance(backend.client, MongoClient) assert backend.database.name == "statements" assert backend.collection.name == "marsha" - assert backend.settings.CONNECTION_URI == "mongodb://localhost:27017/" + assert str(backend.settings.CONNECTION_URI) == "mongodb://localhost:27017/" assert backend.settings.CLIENT_OPTIONS == MongoClientOptions() assert backend.settings.LOCALE_ENCODING == "utf8" assert backend.settings.READ_CHUNK_SIZE == 500 @@ -75,7 +75,7 @@ def test_backends_data_mongo_instantiation_with_settings(): backend = MongoDataBackend(settings) assert backend.database.name == MONGO_TEST_DATABASE assert backend.collection.name == "foo" - assert backend.settings.CONNECTION_URI == MONGO_TEST_CONNECTION_URI + assert str(backend.settings.CONNECTION_URI) == MONGO_TEST_CONNECTION_URI assert backend.settings.CLIENT_OPTIONS == MongoClientOptions(tz_aware=True) assert backend.settings.LOCALE_ENCODING == "utf8" assert backend.settings.READ_CHUNK_SIZE == 1000 diff --git a/tests/backends/lrs/test_async_es.py b/tests/backends/lrs/test_async_es.py index f4ebcbb8c..30eaaffe0 100644 --- a/tests/backends/lrs/test_async_es.py +++ b/tests/backends/lrs/test_async_es.py @@ -276,7 +276,7 @@ async def test_backends_lrs_async_es_query_statements_query( async def mock_read(query, chunk_size): """Mock the `AsyncESLRSBackend.read` method.""" - assert query.dict() == expected_query + assert query.model_dump() == expected_query assert chunk_size == expected_query.get("size") query.pit.id = "foo_pit_id" query.search_after = ["bar_search_after", "baz_search_after"] @@ -284,7 +284,9 @@ async def mock_read(query, chunk_size): backend = async_es_lrs_backend() monkeypatch.setattr(backend, "read", mock_read) - result = await backend.query_statements(RalphStatementsQuery.construct(**params)) + result = await backend.query_statements( + RalphStatementsQuery.model_construct(**params) + ) assert result.statements == [{}] assert result.pit_id == "foo_pit_id" assert result.search_after == "bar_search_after|baz_search_after" @@ -305,7 +307,9 @@ async def test_backends_lrs_async_es_query_statements(es, async_es_lrs_backend): assert await backend.write(documents) == 1 # Check the expected search query results. - result = await backend.query_statements(RalphStatementsQuery.construct(limit=10)) + result = await backend.query_statements( + RalphStatementsQuery.model_construct(limit=10) + ) assert result.statements == documents assert re.match(r"[0-9]+\|0", result.search_after) @@ -331,7 +335,7 @@ async def mock_read(**_): msg = "Query error" with pytest.raises(BackendException, match=msg): with caplog.at_level(logging.ERROR): - await backend.query_statements(RalphStatementsQuery.construct()) + await backend.query_statements(RalphStatementsQuery.model_construct()) await backend.close() @@ -363,7 +367,7 @@ def mock_search(**_): _ = [ statement async for statement in backend.query_statements_by_ids( - RalphStatementsQuery.construct() + RalphStatementsQuery.model_construct() ) ] diff --git a/tests/backends/lrs/test_async_mongo.py b/tests/backends/lrs/test_async_mongo.py index ca7102f56..745cfe21f 100644 --- a/tests/backends/lrs/test_async_mongo.py +++ b/tests/backends/lrs/test_async_mongo.py @@ -235,13 +235,15 @@ async def test_backends_lrs_async_mongo_query_statements_query( async def mock_read(query, chunk_size): """Mock the `AsyncMongoLRSBackend.read` method.""" - assert query.dict() == expected_query + assert query.model_dump() == expected_query assert chunk_size == expected_query.get("limit") yield {"_id": "search_after_id", "_source": {}} backend = async_mongo_lrs_backend() monkeypatch.setattr(backend, "read", mock_read) - result = await backend.query_statements(RalphStatementsQuery.construct(**params)) + result = await backend.query_statements( + RalphStatementsQuery.model_construct(**params) + ) assert result.statements == [{}] assert not result.pit_id assert result.search_after == "search_after_id" @@ -272,7 +274,7 @@ async def test_backends_lrs_async_mongo_query_statements_with_success( ] assert await backend.write(documents) == 2 - statement_parameters = RalphStatementsQuery.construct( + statement_parameters = RalphStatementsQuery.model_construct( statement_id="62b9ce922c26b46b68ffc68f", agent={ "account__name": "test_name", @@ -313,7 +315,7 @@ async def mock_read(**_): with caplog.at_level(logging.ERROR): with pytest.raises(BackendException, match=msg): - await backend.query_statements(RalphStatementsQuery.construct()) + await backend.query_statements(RalphStatementsQuery.model_construct()) assert ( "ralph.backends.lrs.async_mongo", @@ -345,7 +347,7 @@ async def mock_read(**_): _ = [ statement async for statement in backend.query_statements_by_ids( - RalphStatementsQuery.construct() + RalphStatementsQuery.model_construct() ) ] diff --git a/tests/backends/lrs/test_clickhouse.py b/tests/backends/lrs/test_clickhouse.py index 2124b8925..d808e3ac0 100644 --- a/tests/backends/lrs/test_clickhouse.py +++ b/tests/backends/lrs/test_clickhouse.py @@ -10,6 +10,7 @@ from ralph.backends.lrs.base import RalphStatementsQuery from ralph.backends.lrs.clickhouse import ClickHouseLRSBackend from ralph.exceptions import BackendException +from ralph.models.xapi.base.common import IRI def test_backends_lrs_clickhouse_default_instantiation(monkeypatch, fs): @@ -19,7 +20,7 @@ def test_backends_lrs_clickhouse_default_instantiation(monkeypatch, fs): backend = ClickHouseLRSBackend() assert backend.settings.IDS_CHUNK_SIZE == 10000 - monkeypatch.setenv("RALPH_BACKENDS__LRS__CLICKHOUSE__IDS_CHUNK_SIZE", 1) + monkeypatch.setenv("RALPH_BACKENDS__LRS__CLICKHOUSE__IDS_CHUNK_SIZE", "1") backend = ClickHouseLRSBackend() assert backend.settings.IDS_CHUNK_SIZE == 1 @@ -86,7 +87,7 @@ def test_backends_lrs_clickhouse_default_instantiation(monkeypatch, fs): "sort": "emission_time DESCENDING, event_id DESCENDING", }, ), - # # 3. Query by statementId and agent with mbox_sha1sum IFI. + # 3. Query by statementId and agent with mbox_sha1sum IFI. ( { "statementId": "test_id", @@ -177,8 +178,8 @@ def test_backends_lrs_clickhouse_default_instantiation(monkeypatch, fs): # 6. Query by verb and activity with limit. ( { - "verb": "http://adlnet.gov/expapi/verbs/attended", - "activity": "http://www.example.com/meetings/34534", + "verb": IRI("http://adlnet.gov/expapi/verbs/attended"), + "activity": IRI("http://www.example.com/meetings/34534"), "limit": 100, }, { @@ -266,9 +267,8 @@ def test_backends_database_clickhouse_query_statements_query( clickhouse, clickhouse_lrs_backend, ): - """Test the ClickHouse backend query_statements method, given a search query - failure, should raise a BackendException and log the error. - """ + """Test that the ClickHouse backend query_statements method uses the appropriate + query parameters.""" def mock_read(query, target, ignore_errors): """Mock the `ClickHouseDataBackend.read` method.""" @@ -285,7 +285,8 @@ def mock_read(query, target, ignore_errors): backend = clickhouse_lrs_backend() monkeypatch.setattr(backend, "read", mock_read) - backend.query_statements(RalphStatementsQuery.construct(**params)) + ralph_statements_query = RalphStatementsQuery.model_construct(**params) + backend.query_statements(ralph_statements_query) backend.close() @@ -315,7 +316,7 @@ def test_backends_lrs_clickhouse_query_statements(clickhouse, clickhouse_lrs_bac # Check the expected search query results. result = backend.query_statements( - RalphStatementsQuery.construct(statementId=test_id, limit=10) + RalphStatementsQuery.model_construct(statementId=test_id, limit=10) ) assert result.statements == statements backend.close() @@ -345,7 +346,7 @@ def test_backends_lrs_clickhouse__find(clickhouse, clickhouse_lrs_backend): assert success == 1 # Check the expected search query results. - result = backend.query_statements(RalphStatementsQuery.construct()) + result = backend.query_statements(RalphStatementsQuery.model_construct()) assert result.statements == statements backend.close() @@ -400,7 +401,7 @@ def mock_query(*args, **kwargs): msg = "Failed to read documents: Query error" with pytest.raises(BackendException, match=msg): - next(backend.query_statements(RalphStatementsQuery.construct())) + next(backend.query_statements(RalphStatementsQuery.model_construct())) assert ( "ralph.backends.lrs.clickhouse", diff --git a/tests/backends/lrs/test_es.py b/tests/backends/lrs/test_es.py index 08346a91e..6570fab8e 100644 --- a/tests/backends/lrs/test_es.py +++ b/tests/backends/lrs/test_es.py @@ -275,7 +275,7 @@ def test_backends_lrs_es_query_statements_query( def mock_read(query, chunk_size): """Mock the `ESLRSBackend.read` method.""" - assert query.dict() == expected_query + assert query.model_dump() == expected_query assert chunk_size == expected_query.get("size") query.pit.id = "foo_pit_id" query.search_after = ["bar_search_after", "baz_search_after"] @@ -283,7 +283,7 @@ def mock_read(query, chunk_size): backend = es_lrs_backend() monkeypatch.setattr(backend, "read", mock_read) - result = backend.query_statements(RalphStatementsQuery.construct(**params)) + result = backend.query_statements(RalphStatementsQuery.model_construct(**params)) assert not result.statements assert result.pit_id == "foo_pit_id" assert result.search_after == "bar_search_after|baz_search_after" @@ -303,7 +303,7 @@ def test_backends_lrs_es_query_statements(es, es_lrs_backend): assert backend.write(documents) == 1 # Check the expected search query results. - result = backend.query_statements(RalphStatementsQuery.construct(limit=10)) + result = backend.query_statements(RalphStatementsQuery.model_construct(limit=10)) assert result.statements == documents assert re.match(r"[0-9]+\|0", result.search_after) @@ -327,7 +327,7 @@ def mock_read(**_): msg = "Query error" with pytest.raises(BackendException, match=msg): with caplog.at_level(logging.ERROR): - backend.query_statements(RalphStatementsQuery.construct()) + backend.query_statements(RalphStatementsQuery.model_construct()) assert ( "ralph.backends.lrs.es", @@ -355,7 +355,9 @@ def mock_search(**_): msg = r"Failed to execute Elasticsearch query: ApiError\(None, 'Query error'\)" with pytest.raises(BackendException, match=msg): with caplog.at_level(logging.ERROR): - list(backend.query_statements_by_ids(RalphStatementsQuery.construct())) + list( + backend.query_statements_by_ids(RalphStatementsQuery.model_construct()) + ) assert ( "ralph.backends.lrs.es", diff --git a/tests/backends/lrs/test_fs.py b/tests/backends/lrs/test_fs.py index b6833cda5..c72700878 100644 --- a/tests/backends/lrs/test_fs.py +++ b/tests/backends/lrs/test_fs.py @@ -273,7 +273,7 @@ def test_backends_lrs_fs_query_statements_query( ] backend = fs_lrs_backend() backend.write(statements) - result = backend.query_statements(RalphStatementsQuery.construct(**params)) + result = backend.query_statements(RalphStatementsQuery.model_construct(**params)) ids = [statement.get("id") for statement in result.statements] assert ids == expected_statement_ids diff --git a/tests/backends/lrs/test_mongo.py b/tests/backends/lrs/test_mongo.py index f0e2aba7a..61e90c021 100644 --- a/tests/backends/lrs/test_mongo.py +++ b/tests/backends/lrs/test_mongo.py @@ -234,13 +234,13 @@ def test_backends_lrs_mongo_query_statements_query( def mock_read(query, chunk_size): """Mock the `MongoLRSBackend.read` method.""" - assert query.dict() == expected_query + assert query.model_dump() == expected_query assert chunk_size == expected_query.get("limit") return [{"_id": "search_after_id", "_source": {}}] backend = mongo_lrs_backend() monkeypatch.setattr(backend, "read", mock_read) - result = backend.query_statements(RalphStatementsQuery.construct(**params)) + result = backend.query_statements(RalphStatementsQuery.model_construct(**params)) assert result.statements == [{}] assert not result.pit_id assert result.search_after == "search_after_id" @@ -267,9 +267,9 @@ def test_backends_lrs_mongo_query_statements_with_success(mongo, mongo_lrs_backe ] assert backend.write(documents) == 2 - statement_parameters = RalphStatementsQuery.construct( + statement_parameters = RalphStatementsQuery.model_construct( statementId="62b9ce922c26b46b68ffc68f", - agent=AgentParameters.construct( + agent=AgentParameters.model_construct( account__name="test_name", account__home_page="http://example.com", ), @@ -308,7 +308,7 @@ def mock_read(**_): with caplog.at_level(logging.ERROR): with pytest.raises(BackendException, match=msg): - backend.query_statements(RalphStatementsQuery.construct()) + backend.query_statements(RalphStatementsQuery.model_construct()) assert ( "ralph.backends.lrs.mongo", @@ -337,7 +337,9 @@ def mock_read(**_): with caplog.at_level(logging.ERROR): with pytest.raises(BackendException, match=msg): - list(backend.query_statements_by_ids(RalphStatementsQuery.construct())) + list( + backend.query_statements_by_ids(RalphStatementsQuery.model_construct()) + ) assert ( "ralph.backends.lrs.mongo", diff --git a/tests/conftest.py b/tests/conftest.py index 4ae97197c..dd7331d81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,6 @@ """Module py.test fixtures.""" -from .fixtures import ( - hypothesis_configuration, # noqa: F401 - hypothesis_strategies, # noqa: F401 -) from .fixtures.api import client # noqa: F401 from .fixtures.auth import ( # noqa: F401 basic_auth_credentials, diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 000000000..6be707d30 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,219 @@ +"""Mock model generation for testing.""" + +from decimal import Decimal +from typing import Any, Callable, Dict, Optional + +from polyfactory.factories.base import BaseFactory +from polyfactory.factories.pydantic_factory import ( + ModelFactory as PolyfactoryModelFactory, +) +from polyfactory.factories.pydantic_factory import ( + T, +) +from polyfactory.fields import Ignore +from pydantic import BaseModel + +from ralph.models.edx.navigational.fields.events import NavigationalEventField +from ralph.models.edx.navigational.statements import UISeqNext, UISeqPrev +from ralph.models.xapi.base.common import IRI, LanguageTag, MailtoEmail +from ralph.models.xapi.base.contexts import ( + BaseXapiContext, +) +from ralph.models.xapi.base.results import BaseXapiResultScore +from ralph.models.xapi.lms.contexts import ( + LMSContextContextActivities, + LMSProfileActivity, +) +from ralph.models.xapi.video.contexts import ( + VideoContextContextActivities, + VideoProfileActivity, +) +from ralph.models.xapi.virtual_classroom.contexts import ( + VirtualClassroomAnsweredPollContextActivities, + VirtualClassroomContextContextActivities, + VirtualClassroomPostedPublicMessageContextActivities, + VirtualClassroomProfileActivity, + VirtualClassroomStartedPollContextActivities, +) + + +def prune(d: Any, exemptions: Optional[list] = None): + """Remove all empty leaves from a dict, except fo those in `exemptions`.""" + + if exemptions is None: + exemptions = [] + + if isinstance(d, BaseModel): + d = d.model_dump() + if isinstance(d, dict): + d_dict_not_exemptions = { + k: prune(v) for k, v in d.items() if k not in exemptions + } + d_dict_not_exemptions = {k: v for k, v in d.items() if v} + d_dict_exemptions = {k: v for k, v in d.items() if k in exemptions} + return {**d_dict_not_exemptions, **d_dict_exemptions} + elif isinstance(d, list): + d_list = [prune(v) for v in d] + return [v for v in d_list if v] + if d: + return d + return False + + +class ModelFactory(PolyfactoryModelFactory[T]): + __allow_none_optionals__ = False + __is_base_factory__ = True + + @classmethod + def get_provider_map(cls) -> Dict[Any, Callable[[], Any]]: + provider_map = super().get_provider_map() + return provider_map + + @classmethod + def _get_or_create_factory(cls, model: type): + created_factory = super()._get_or_create_factory(model) + created_factory.get_provider_map = cls.get_provider_map + created_factory._get_or_create_factory = cls._get_or_create_factory + return created_factory + + +class BaseXapiResultScoreFactory(ModelFactory[BaseXapiResultScore]): + __set_as_default_factory_for_type__ = True + __model__ = BaseXapiResultScore + + min = Decimal("0.0") + max = Decimal("20.0") + raw = Decimal("11") + + +class BaseXapiContextFactory(ModelFactory[BaseXapiContext]): + __model__ = BaseXapiContext + __set_as_default_factory_for_type__ = True + + revision = Ignore() + platform = Ignore() + + +class LMSContextContextActivitiesFactory(ModelFactory[LMSContextContextActivities]): + __model__ = LMSContextContextActivities + __set_as_default_factory_for_type__ = True + + category = lambda: mock_xapi_instance(LMSProfileActivity) # noqa: E731 + + +class VideoContextContextActivitiesFactory(ModelFactory[VideoContextContextActivities]): + __model__ = VideoContextContextActivities + __set_as_default_factory_for_type__ = True + + category = lambda: mock_xapi_instance(VideoProfileActivity) # noqa: E731 + + +class VirtualClassroomStartedPollContextActivitiesFactory( + ModelFactory[VirtualClassroomStartedPollContextActivities] +): + __model__ = VirtualClassroomStartedPollContextActivities + __set_as_default_factory_for_type__ = True + + category = lambda: mock_xapi_instance(VirtualClassroomProfileActivity) # noqa: E731 + + +class VirtualClassroomAnsweredPollContextActivitiesFactory( + ModelFactory[VirtualClassroomAnsweredPollContextActivities] +): + __model__ = VirtualClassroomAnsweredPollContextActivities + __set_as_default_factory_for_type__ = True + + category = lambda: mock_xapi_instance(VirtualClassroomProfileActivity) # noqa: E731 + + +class VirtualClassroomPostedPublicMessageContextActivitiesFactory( + ModelFactory[VirtualClassroomPostedPublicMessageContextActivities] +): + __model__ = VirtualClassroomPostedPublicMessageContextActivities + __set_as_default_factory_for_type__ = True + + category = lambda: mock_xapi_instance(VirtualClassroomProfileActivity) # noqa: E731 + + +class UISeqPrevFactory(ModelFactory[UISeqPrev]): + __model__ = UISeqPrev + __set_as_default_factory_for_type__ = True + + event = lambda: mock_instance(NavigationalEventField, old=1, new=0) # noqa: E731 + + +class UISeqNextFactory(ModelFactory[UISeqNext]): + __model__ = UISeqNext + __set_as_default_factory_for_type__ = True + + event = lambda: mock_instance(NavigationalEventField, old=0, new=1) # noqa: E731 + + +class VirtualClassroomContextContextActivitiesFactory( + ModelFactory[VirtualClassroomContextContextActivities] +): + __model__ = VirtualClassroomContextContextActivities + __set_as_default_factory_for_type__ = True + + category = lambda: mock_instance(VirtualClassroomProfileActivity) # noqa: E731 + + +class LanguageTagFactory(ModelFactory[LanguageTag]): + __model__ = LanguageTag + __set_as_default_factory_for_type__ = True + + root = lambda: LanguageTag("en-US") # noqa: E731 + + +class IRIFactory(ModelFactory[IRI]): + __model__ = IRI + __set_as_default_factory_for_type__ = True + + root = lambda: IRI(mock_url()) # noqa: E731 + + +class MailtoEmailFactory(ModelFactory[MailtoEmail]): + __model__ = MailtoEmail + __set_as_default_factory_for_type__ = True + + root = lambda: "mailto:test@example.com" # noqa: E731 + + +def mock_xapi_instance(klass, *args, **kwargs): + """Generate a mock instance of a given xAPI model.""" + + # Avoid redifining custom factories + if klass not in BaseFactory._factory_type_mapping: + + class KlassFactory(ModelFactory[klass]): + __model__ = klass + + else: + KlassFactory = BaseFactory._factory_type_mapping[klass] + + kwargs = KlassFactory.process_kwargs(*args, **kwargs) + + # Remove `None` values + kwargs = prune(kwargs, exemptions=["extensions"]) + + return klass(**kwargs) + + +def mock_instance(klass, *args, **kwargs): + """Generate a mock instance of a given model.""" + + # Avoid redifining custom factories + if klass not in BaseFactory._factory_type_mapping: + + class KlassFactory(ModelFactory[klass]): + __model__ = klass + + else: + KlassFactory = BaseFactory._factory_type_mapping[klass] + + return KlassFactory.build(*args, **kwargs) + + +def mock_url(): + """Mock a URL.""" + return ModelFactory.__faker__.url() diff --git a/tests/fixtures/backends.py b/tests/fixtures/backends.py index 428e971b4..7d53b3097 100644 --- a/tests/fixtures/backends.py +++ b/tests/fixtures/backends.py @@ -227,7 +227,7 @@ def fs_lrs_backend(fs, settings_fs): def get_fs_lrs_backend(path: str = "foo"): """Return an instance of FSLRSBackend.""" settings = FSLRSBackend.settings_class( - DEFAULT_DIRECTORY_PATH=path, + DEFAULT_DIRECTORY_PATH=Path(path), DEFAULT_QUERY_STRING="*", LOCALE_ENCODING="utf8", READ_CHUNK_SIZE=1024, @@ -291,7 +291,7 @@ def _get_lrs_test_backend( BASE_URL=parse_obj_as(AnyHttpUrl, base_url), USERNAME="user", PASSWORD="pass", - HEADERS=LRSHeaders.parse_obj(headers), + HEADERS=LRSHeaders.model_validate(headers), LOCALE_ENCODING="utf8", STATUS_ENDPOINT="/__heartbeat__", STATEMENTS_ENDPOINT="/xAPI/statements/", @@ -444,7 +444,7 @@ def get_clickhouse_fixture( """ client_options = ClickHouseClientOptions( date_time_input_format="best_effort", # Allows RFC dates - ).dict() + ).model_dump() client = clickhouse_connect.get_client( host=host, diff --git a/tests/fixtures/hypothesis_configuration.py b/tests/fixtures/hypothesis_configuration.py deleted file mode 100644 index f7c7844b0..000000000 --- a/tests/fixtures/hypothesis_configuration.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Hypothesis fixture configuration.""" - -import operator - -from hypothesis import provisional, settings -from hypothesis import strategies as st -from pydantic import AnyHttpUrl, AnyUrl, StrictStr - -from ralph.models.xapi.base.common import IRI, LanguageTag, MailtoEmail - -settings.register_profile("development", max_examples=1) -settings.load_profile("development") - -st.register_type_strategy(str, st.text(min_size=1)) -st.register_type_strategy(StrictStr, st.text(min_size=1)) -st.register_type_strategy(AnyUrl, provisional.urls()) -st.register_type_strategy(AnyHttpUrl, provisional.urls()) -st.register_type_strategy(IRI, provisional.urls()) -st.register_type_strategy( - MailtoEmail, st.builds(operator.add, st.just("mailto:"), st.emails()) -) -st.register_type_strategy(LanguageTag, st.just("en-US")) diff --git a/tests/fixtures/hypothesis_strategies.py b/tests/fixtures/hypothesis_strategies.py deleted file mode 100644 index 0d73f53ff..000000000 --- a/tests/fixtures/hypothesis_strategies.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Hypothesis build strategies with special constraints.""" - -import random -from typing import Union - -from hypothesis import given -from hypothesis import strategies as st -from pydantic import BaseModel - -from ralph.models.edx.navigational.fields.events import NavigationalEventField -from ralph.models.edx.navigational.statements import UISeqNext, UISeqPrev -from ralph.models.xapi.base.contexts import BaseXapiContext -from ralph.models.xapi.base.results import BaseXapiResultScore -from ralph.models.xapi.lms.contexts import ( - LMSContextContextActivities, - LMSProfileActivity, -) -from ralph.models.xapi.video.contexts import ( - VideoContextContextActivities, - VideoProfileActivity, -) -from ralph.models.xapi.virtual_classroom.contexts import ( - VirtualClassroomContextContextActivities, - VirtualClassroomProfileActivity, -) - -OVERWRITTEN_STRATEGIES = {} - - -def is_base_model(klass): - """Return True if the given class is a subclass of the pydantic BaseModel.""" - - try: - return issubclass(klass, BaseModel) - except TypeError: - return False - - -def get_strategy_from(annotation): - """Infer a Hypothesis strategy from the given annotation.""" - origin = getattr(annotation, "__origin__", None) - args = getattr(annotation, "__args__", None) - if is_base_model(annotation): - return custom_builds(annotation) - if origin is Union: - return st.one_of( - [get_strategy_from(t) for t in args if not isinstance(t, type(None))] - ) - if origin is list: - return st.lists(get_strategy_from(args[0]), min_size=1) - if origin is dict: - keys = get_strategy_from(args[0]) - values = get_strategy_from(args[1]) - return st.dictionaries(keys, values, min_size=1) - if annotation is None: - return st.none() - return st.from_type(annotation) - - -def custom_builds( - klass: BaseModel, _overwrite_default=True, **kwargs: Union[st.SearchStrategy, bool] -): - """Return a fixed_dictionaries Hypothesis strategy for pydantic models. - - Args: - klass (BaseModel): The pydantic model for which to generate a strategy. - _overwrite_default (bool): By default, fields overwritten by kwargs become - required. If _overwrite_default is set to False, we keep the original field - requirement (either required or optional). - **kwargs (SearchStrategy or bool): If kwargs contain search strategies, they - overwrite the default strategy for the given key. - If kwargs contains booleans, they set whether the given key should be - present (True) or omitted (False) in the generated model. - """ - - for special_class, special_kwargs in OVERWRITTEN_STRATEGIES.items(): - if issubclass(klass, special_class): - kwargs = dict(special_kwargs, **kwargs) - break - optional = {} - required = {} - for name, field in klass.__fields__.items(): - arg = kwargs.get(name, None) - if arg is False: - continue - is_required = field.required or (arg is not None and _overwrite_default) - required_optional = required if is_required or arg is not None else optional - field_strategy = get_strategy_from(field.outer_type_) if arg is None else arg - required_optional[field.alias] = field_strategy - if not required: - # To avoid generating empty values - key, value = random.choice(list(optional.items())) - required[key] = value - del optional[key] - return st.fixed_dictionaries(required, optional=optional).map(klass.parse_obj) - - -def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs): - """Wrap the Hypothesis `given` function. Replace st.builds with custom_builds.""" - strategies = [] - for arg in args: - strategies.append(custom_builds(arg) if is_base_model(arg) else arg) - return given(*strategies, **kwargs) - - -OVERWRITTEN_STRATEGIES = { - UISeqPrev: { - "event": custom_builds(NavigationalEventField, old=st.just(1), new=st.just(0)) - }, - UISeqNext: { - "event": custom_builds(NavigationalEventField, old=st.just(0), new=st.just(1)) - }, - BaseXapiContext: { - "revision": False, - "platform": False, - }, - BaseXapiResultScore: { - "raw": False, - "min": False, - "max": False, - }, - LMSContextContextActivities: {"category": custom_builds(LMSProfileActivity)}, - VideoContextContextActivities: {"category": custom_builds(VideoProfileActivity)}, - VirtualClassroomContextContextActivities: { - "category": custom_builds(VirtualClassroomProfileActivity) - }, -} diff --git a/tests/helpers.py b/tests/helpers.py index fca575966..359f8bd5c 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -162,14 +162,22 @@ def mock_statement( # Verb if verb is None: - verb = {"id": f"https://w3id.org/xapi/video/verbs/{random.random()}"} + verb = { + "id": ( + "https://w3id.org/xapi/video/verbs/" + f"{str(random.random()).replace('.', '_')}" + ) + } elif isinstance(verb, int): verb = {"id": f"https://w3id.org/xapi/video/verbs/{verb}"} # Object if object is None: object = { - "id": f"http://example.adlnet.gov/xapi/example/activity_{random.random()}" + "id": ( + "http://example.adlnet.gov/xapi/example/activity_" + f"{str(random.random()).replace('.', '_')}" + ) } elif isinstance(object, int): object = {"id": f"http://example.adlnet.gov/xapi/example/activity_{object}"} diff --git a/tests/models/edx/converters/xapi/test_base.py b/tests/models/edx/converters/xapi/test_base.py index 2276de493..4e7cf2033 100644 --- a/tests/models/edx/converters/xapi/test_base.py +++ b/tests/models/edx/converters/xapi/test_base.py @@ -22,7 +22,7 @@ def _get_conversion_items(self): return set() converter = DummyBaseXapiConverter(uuid_namespace, "https://fun-mooc.fr") - assert converter.platform_url == "https://fun-mooc.fr" + assert str(converter.platform_url) == "https://fun-mooc.fr" assert converter.uuid_namespace == UUID(uuid_namespace) diff --git a/tests/models/edx/converters/xapi/test_enrollment.py b/tests/models/edx/converters/xapi/test_enrollment.py index 56b57a2b7..bdaa9206b 100644 --- a/tests/models/edx/converters/xapi/test_enrollment.py +++ b/tests/models/edx/converters/xapi/test_enrollment.py @@ -4,7 +4,6 @@ from uuid import UUID, uuid5 import pytest -from hypothesis import provisional from ralph.models.converter import convert_dict_event from ralph.models.edx.converters.xapi.enrollment import ( @@ -16,28 +15,31 @@ EdxCourseEnrollmentDeactivated, ) -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance, mock_url -@custom_given(EdxCourseEnrollmentActivated, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_enrollment_edx_course_enrollment_activated_to_lms_registered_course( # noqa: E501 - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that converting with `EdxCourseEnrollmentActivatedToLMSRegisteredCourse` returns the expected xAPI statement. """ + event = mock_instance(EdxCourseEnrollmentActivated) + platform_url = mock_url() event.event.course_id = "edX/DemoX/Demo_Course" event.context.user_id = "1" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, EdxCourseEnrollmentActivatedToLMSRegisteredCourse(uuid_namespace, platform_url), ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), @@ -56,7 +58,10 @@ def test_models_edx_converters_xapi_enrollment_edx_course_enrollment_activated_t }, }, "object": { - "id": f"{platform_url}/courses/{event['event']['course_id']}/info", + "id": ( + f"{platform_url.rstrip('/')}/courses/" + f"{event['event']['course_id']}/info" + ), "definition": { "type": "http://adlnet.gov/expapi/activities/course", }, @@ -66,19 +71,20 @@ def test_models_edx_converters_xapi_enrollment_edx_course_enrollment_activated_t } -@custom_given(EdxCourseEnrollmentDeactivated, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_enrollment_edx_course_enrollment_deactivated_to_lms_unregistered_course( # noqa: E501 - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that converting with `EdxCourseEnrollmentDeactivatedToLMSUnregisteredCourse` returns the expected xAPI statement. """ + event = mock_instance(EdxCourseEnrollmentDeactivated) + platform_url = mock_url() event.event.course_id = "edX/DemoX/Demo_Course" event.context.user_id = "1" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, @@ -87,7 +93,9 @@ def test_models_edx_converters_xapi_enrollment_edx_course_enrollment_deactivated uuid_namespace, platform_url ), ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), @@ -106,7 +114,10 @@ def test_models_edx_converters_xapi_enrollment_edx_course_enrollment_deactivated }, }, "object": { - "id": f"{platform_url}/courses/{event['event']['course_id']}/info", + "id": ( + f"{platform_url.rstrip('/')}/courses/" + f"{event['event']['course_id']}/info" + ), "definition": { "type": "http://adlnet.gov/expapi/activities/course", }, diff --git a/tests/models/edx/converters/xapi/test_navigational.py b/tests/models/edx/converters/xapi/test_navigational.py index 011d1c622..95cc66dc7 100644 --- a/tests/models/edx/converters/xapi/test_navigational.py +++ b/tests/models/edx/converters/xapi/test_navigational.py @@ -4,30 +4,33 @@ from uuid import UUID, uuid5 import pytest -from hypothesis import provisional from ralph.models.converter import convert_dict_event from ralph.models.edx.converters.xapi.navigational import UIPageCloseToPageTerminated from ralph.models.edx.navigational.statements import UIPageClose -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance, mock_url -@custom_given(UIPageClose, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_navigational_ui_page_close_to_page_terminated( - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that converting with UIPageCloseToPageTerminated returns the expected xAPI statement. """ + event = mock_instance(UIPageClose) + platform_url = mock_url() + event.context.user_id = "1" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, UIPageCloseToPageTerminated(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), "actor": { diff --git a/tests/models/edx/converters/xapi/test_server.py b/tests/models/edx/converters/xapi/test_server.py index 8c0244494..67aae5a82 100644 --- a/tests/models/edx/converters/xapi/test_server.py +++ b/tests/models/edx/converters/xapi/test_server.py @@ -4,24 +4,25 @@ from uuid import UUID, uuid5 import pytest -from hypothesis import provisional, settings from ralph.models.converter import convert_dict_event, convert_str_event from ralph.models.edx.converters.xapi.server import ServerEventToPageViewed from ralph.models.edx.server import Server -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance, mock_url -@custom_given(Server, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_server_server_event_to_page_viewed_constant_uuid( - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that `ServerEventToPageViewed.convert` returns a JSON string with a constant UUID. """ - event_str = event.json() + event = mock_instance(Server) + platform_url = mock_url() + + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event1 = convert_str_event( event_str, ServerEventToPageViewed(uuid_namespace, platform_url) @@ -32,22 +33,24 @@ def test_models_edx_converters_xapi_server_server_event_to_page_viewed_constant_ assert xapi_event1.id == xapi_event2.id -@custom_given(Server, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) -def test_models_edx_converters_xapi_server_server_event_to_page_viewed( - uuid_namespace, event, platform_url -): +def test_models_edx_converters_xapi_server_server_event_to_page_viewed(uuid_namespace): """Test that converting with `ServerEventToPageViewed` returns the expected xAPI statement. """ + event = mock_instance(Server) + platform_url = mock_url() + event.event_type = "/main/blog" event.context.user_id = "1" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, ServerEventToPageViewed(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), "actor": { @@ -57,7 +60,7 @@ def test_models_edx_converters_xapi_server_server_event_to_page_viewed( "definition": { "type": "http://activitystrea.ms/schema/1.0/page", }, - "id": platform_url + "/main/blog", + "id": platform_url.rstrip("/") + "/main/blog", }, "timestamp": event["time"], "verb": { @@ -67,16 +70,16 @@ def test_models_edx_converters_xapi_server_server_event_to_page_viewed( } -@settings(deadline=None) -@custom_given(Server, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_server_server_event_to_page_viewed_with_anonymous_user( # noqa: E501 - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that anonymous usernames are replaced with `anonymous`.""" + event = mock_instance(Server) + platform_url = mock_url() event.context.user_id = "" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, ServerEventToPageViewed(uuid_namespace, platform_url) diff --git a/tests/models/edx/converters/xapi/test_video.py b/tests/models/edx/converters/xapi/test_video.py index 914c05a46..0c3d27698 100644 --- a/tests/models/edx/converters/xapi/test_video.py +++ b/tests/models/edx/converters/xapi/test_video.py @@ -4,7 +4,6 @@ from uuid import UUID, uuid5 import pytest -from hypothesis import provisional from ralph.models.converter import convert_dict_event from ralph.models.edx.converters.xapi.video import ( @@ -22,26 +21,32 @@ UIStopVideo, ) -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance, mock_url -@custom_given(UILoadVideo, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_video_ui_load_video_to_video_initialized( - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that converting with `UILoadVideoToVideoInitialized` returns the expected xAPI statement. """ + event = mock_instance(UILoadVideo) + event.context.course_id = "course-v1:a+b+c" + + platform_url = mock_url() + event.context.user_id = "1" event.session = "af45a0e650c4a4fdb0bcde75a1e4b694" session_uuid = "af45a0e6-50c4-a4fd-b0bc-de75a1e4b694" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, UILoadVideoToVideoInitialized(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), @@ -65,7 +70,7 @@ def test_models_edx_converters_xapi_video_ui_load_video_to_video_initialized( }, }, "object": { - "id": platform_url + "id": platform_url.rstrip("/") + "/xblock/block-v1:" + event["context"]["course_id"] + "-course-v1:+type@video+block@" @@ -80,29 +85,33 @@ def test_models_edx_converters_xapi_video_ui_load_video_to_video_initialized( } -@custom_given(UIPlayVideo, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) -def test_models_edx_converters_xapi_video_ui_play_video_to_video_played( - uuid_namespace, event, platform_url -): +def test_models_edx_converters_xapi_video_ui_play_video_to_video_played(uuid_namespace): """Test that converting with `UIPlayVideoToVideoPlayed` returns the expected xAPI statement. """ + event = mock_instance(UIPlayVideo) + event.context.course_id = "course-v1:a+b+c" + + platform_url = mock_url() + event.context.user_id = "1" event.session = "af45a0e650c4a4fdb0bcde75a1e4b694" session_uuid = "af45a0e6-50c4-a4fd-b0bc-de75a1e4b694" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, UIPlayVideoToVideoPlayed(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), "actor": {"account": {"homePage": platform_url, "name": "1"}}, "verb": {"id": "https://w3id.org/xapi/video/verbs/played"}, "object": { - "id": platform_url + "id": platform_url.rstrip("/") + "/xblock/block-v1:" + event["context"]["course_id"] + "-course-v1:+type@video+block@" @@ -139,29 +148,35 @@ def test_models_edx_converters_xapi_video_ui_play_video_to_video_played( } -@custom_given(UIPauseVideo, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_video_ui_pause_video_to_video_paused( - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that converting with `UIPauseVideoToVideoPaused` returns the expected xAPI statement. """ + event = mock_instance(UIPauseVideo) + event.context.course_id = "course-v1:a+b+c" + + platform_url = mock_url() + event.context.user_id = "1" event.session = "af45a0e650c4a4fdb0bcde75a1e4b694" session_uuid = "af45a0e6-50c4-a4fd-b0bc-de75a1e4b694" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, UIPauseVideoToVideoPaused(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), "actor": {"account": {"homePage": platform_url, "name": "1"}}, "verb": {"id": "https://w3id.org/xapi/video/verbs/paused"}, "object": { - "id": platform_url + "id": platform_url.rstrip("/") + "/xblock/block-v1:" + event["context"]["course_id"] + "-course-v1:+type@video+block@" @@ -199,29 +214,35 @@ def test_models_edx_converters_xapi_video_ui_pause_video_to_video_paused( } -@custom_given(UIStopVideo, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) def test_models_edx_converters_xapi_video_ui_stop_video_to_video_terminated( - uuid_namespace, event, platform_url + uuid_namespace, ): """Test that converting with `UIStopVideoToVideoTerminated` returns the expected xAPI statement. """ + event = mock_instance(UIStopVideo) + event.context.course_id = "course-v1:a+b+c" + + platform_url = mock_url() + event.context.user_id = "1" event.session = "af45a0e650c4a4fdb0bcde75a1e4b694" session_uuid = "af45a0e6-50c4-a4fd-b0bc-de75a1e4b694" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, UIStopVideoToVideoTerminated(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), "actor": {"account": {"homePage": platform_url, "name": "1"}}, "verb": {"id": "http://adlnet.gov/expapi/verbs/terminated"}, "object": { - "id": platform_url + "id": platform_url.rstrip("/") + "/xblock/block-v1:" + event["context"]["course_id"] + "-course-v1:+type@video+block@" @@ -260,29 +281,33 @@ def test_models_edx_converters_xapi_video_ui_stop_video_to_video_terminated( } -@custom_given(UISeekVideo, provisional.urls()) @pytest.mark.parametrize("uuid_namespace", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) -def test_models_edx_converters_xapi_video_ui_seek_video_to_video_seeked( - uuid_namespace, event, platform_url -): +def test_models_edx_converters_xapi_video_ui_seek_video_to_video_seeked(uuid_namespace): """Test that converting with `UISeekVideoToVideoSeeked` returns the expected xAPI statement. """ + event = mock_instance(UISeekVideo) + event.context.course_id = "course-v1:a+b+c" + + platform_url = mock_url() + event.context.user_id = "1" event.session = "af45a0e650c4a4fdb0bcde75a1e4b694" session_uuid = "af45a0e6-50c4-a4fd-b0bc-de75a1e4b694" - event_str = event.json() + event_str = event.model_dump_json() event = json.loads(event_str) xapi_event = convert_dict_event( event, event_str, UISeekVideoToVideoSeeked(uuid_namespace, platform_url) ) - xapi_event_dict = json.loads(xapi_event.json(exclude_none=True, by_alias=True)) + xapi_event_dict = json.loads( + xapi_event.model_dump_json(exclude_none=True, by_alias=True) + ) assert xapi_event_dict == { "id": str(uuid5(UUID(uuid_namespace), event_str)), "actor": {"account": {"homePage": platform_url, "name": "1"}}, "verb": {"id": "https://w3id.org/xapi/video/verbs/seeked"}, "object": { - "id": platform_url + "id": platform_url.rstrip("/") + "/xblock/block-v1:" + event["context"]["course_id"] + "-course-v1:+type@video+block@" diff --git a/tests/models/edx/navigational/test_events.py b/tests/models/edx/navigational/test_events.py index c40bbf016..5258c5b3a 100644 --- a/tests/models/edx/navigational/test_events.py +++ b/tests/models/edx/navigational/test_events.py @@ -4,18 +4,18 @@ import re import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.navigational.fields.events import NavigationalEventField -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(NavigationalEventField) -def test_fields_edx_navigational_events_event_field_with_valid_content(field): +def test_fields_edx_navigational_events_event_field_with_valid_content(): """Test that a valid `NavigationalEventField` does not raise a `ValidationError`. """ + field = mock_instance(NavigationalEventField) assert re.match( ( @@ -53,12 +53,13 @@ def test_fields_edx_navigational_events_event_field_with_valid_content(field): ), ], ) -@custom_given(NavigationalEventField) -def test_fields_edx_navigational_events_event_field_with_invalid_content(id, field): +def test_fields_edx_navigational_events_event_field_with_invalid_content(id): """Test that an invalid `NavigationalEventField` raises a `ValidationError`.""" - invalid_field = json.loads(field.json()) + field = mock_instance(NavigationalEventField) + + invalid_field = json.loads(field.model_dump_json()) invalid_field["id"] = id - with pytest.raises(ValidationError, match="id\n string does not match regex"): + with pytest.raises(ValidationError, match="id\n String should match pattern"): NavigationalEventField(**invalid_field) diff --git a/tests/models/edx/navigational/test_statements.py b/tests/models/edx/navigational/test_statements.py index 7d512c672..05a89f53f 100644 --- a/tests/models/edx/navigational/test_statements.py +++ b/tests/models/edx/navigational/test_statements.py @@ -4,8 +4,7 @@ import re import pytest -from hypothesis import strategies as st -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.navigational.fields.events import NavigationalEventField from ralph.models.edx.navigational.statements import ( @@ -16,7 +15,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -28,21 +27,21 @@ UISeqPrev, ], ) -@custom_given(st.data()) -def test_models_edx_navigational_selectors_with_valid_statements(class_, data): +def test_models_edx_navigational_selectors_with_valid_statements(class_): """Test given a valid navigational edX statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(NavigationalEventField) -def test_fields_edx_navigational_events_event_field_with_valid_content(field): +def test_fields_edx_navigational_events_event_field_with_valid_content(): """Test that a valid `NavigationalEventField` does not raise a `ValidationError`. """ + field = mock_instance(NavigationalEventField) + assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+" @@ -79,71 +78,72 @@ def test_fields_edx_navigational_events_event_field_with_valid_content(field): ), ], ) -@custom_given(NavigationalEventField) -def test_fields_edx_navigational_events_event_field_with_invalid_content(id, field): +def test_fields_edx_navigational_events_event_field_with_invalid_content(id): """Test that an invalid `NavigationalEventField` raises a `ValidationError`.""" - invalid_field = json.loads(field.json()) + field = mock_instance(NavigationalEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["id"] = id - with pytest.raises(ValidationError, match="id\n string does not match regex"): + with pytest.raises(ValidationError, match="id\n String should match pattern"): NavigationalEventField(**invalid_field) -@custom_given(UIPageClose) -def test_models_edx_ui_page_close_with_valid_statement(statement): +def test_models_edx_ui_page_close_with_valid_statement(): """Test that a `page_close` statement has the expected `event`, `event_type` and `name`. """ + statement = mock_instance(UIPageClose) assert statement.event == "{}" assert statement.event_type == "page_close" assert statement.name == "page_close" -@custom_given(UISeqGoto) -def test_models_edx_ui_seq_goto_with_valid_statement(statement): +def test_models_edx_ui_seq_goto_with_valid_statement(): """Test that a `seq_goto` statement has the expected `event_type` and `name`.""" + statement = mock_instance(UISeqGoto) assert statement.event_type == "seq_goto" assert statement.name == "seq_goto" -@custom_given(UISeqNext) -def test_models_edx_ui_seq_next_with_valid_statement(statement): +def test_models_edx_ui_seq_next_with_valid_statement(): """Test that a `seq_next` statement has the expected `event_type` and `name`.""" + statement = mock_instance(UISeqNext) assert statement.event_type == "seq_next" assert statement.name == "seq_next" @pytest.mark.parametrize("old,new", [("0", "10"), ("10", "0")]) -@custom_given(UISeqNext) -def test_models_edx_ui_seq_next_with_invalid_statement(old, new, event): +def test_models_edx_ui_seq_next_with_invalid_statement(old, new): """Test that an invalid `seq_next` event raises a ValidationError.""" - invalid_event = json.loads(event.json()) + event = mock_instance(UISeqNext) + invalid_event = json.loads(event.model_dump_json()) invalid_event["event"]["old"] = old invalid_event["event"]["new"] = new with pytest.raises( ValidationError, - match="event\n event.new - event.old should be equal to 1", + match="event\n Value error, event.new - event.old should be equal to 1", ): UISeqNext(**invalid_event) -@custom_given(UISeqPrev) -def test_models_edx_ui_seq_prev_with_valid_statement(statement): +def test_models_edx_ui_seq_prev_with_valid_statement(): """Test that a `seq_prev` statement has the expected `event_type` and `name`.""" + statement = mock_instance(UISeqPrev) assert statement.event_type == "seq_prev" assert statement.name == "seq_prev" @pytest.mark.parametrize("old,new", [("0", "10"), ("10", "0")]) -@custom_given(UISeqPrev) -def test_models_edx_ui_seq_prev_with_invalid_statement(old, new, event): +def test_models_edx_ui_seq_prev_with_invalid_statement(old, new): """Test that an invalid `seq_prev` event raises a ValidationError.""" - invalid_event = json.loads(event.json()) + event = mock_instance(UISeqPrev) + invalid_event = json.loads(event.model_dump_json()) invalid_event["event"]["old"] = old invalid_event["event"]["new"] = new with pytest.raises( - ValidationError, match="event\n event.old - event.new should be equal to 1" + ValidationError, + match="event\n Value error, event.old - event.new should be equal to 1", ): UISeqPrev(**invalid_event) diff --git a/tests/models/edx/open_response_assessment/test_events.py b/tests/models/edx/open_response_assessment/test_events.py index 614f2bc98..e9dc3c19c 100644 --- a/tests/models/edx/open_response_assessment/test_events.py +++ b/tests/models/edx/open_response_assessment/test_events.py @@ -13,38 +13,36 @@ ORAGetSubmissionForStaffGradingEventField, ) -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(ORAGetPeerSubmissionEventField) -def test_models_edx_ora_get_peer_submission_event_field_with_valid_values(field): +def test_models_edx_ora_get_peer_submission_event_field_with_valid_values(): """Test that a valid `ORAGetPeerSubmissionEventField` does not raise a `ValidationError`. """ + field = mock_instance(ORAGetPeerSubmissionEventField) assert re.match( r"^block-v1:.+\+.+\+.+type@openassessment+block@[a-f0-9]{32}$", field.item_id ) -@custom_given(ORAGetSubmissionForStaffGradingEventField) -def test_models_edx_ora_get_submission_for_staff_grading_event_field_with_valid_values( - field, -): +def test_models_edx_ora_get_submission_for_staff_grading_event_field_with_valid_values(): # noqa: E501 """Test that a valid `ORAGetSubmissionForStaffGradingEventField` does not raise a `ValidationError`. """ + field = mock_instance(ORAGetSubmissionForStaffGradingEventField) assert re.match( r"^block-v1:.+\+.+\+.+type@openassessment+block@[a-f0-9]{32}$", field.item_id ) -@custom_given(ORAAssessEventField) -def test_models_edx_ora_assess_event_field_with_valid_values(field): +def test_models_edx_ora_assess_event_field_with_valid_values(): """Test that a valid `ORAAssessEventField` does not raise a `ValidationError`. """ + field = mock_instance(ORAAssessEventField) assert field.score_type in {"PE", "SE", "ST"} @@ -53,14 +51,16 @@ def test_models_edx_ora_assess_event_field_with_valid_values(field): "score_type", ["pe", "se", "st", "SA", "PA", "22", "&T"], ) -@custom_given(ORAAssessEventField) -def test_models_edx_ora_assess_event_field_with_invalid_values(score_type, field): +def test_models_edx_ora_assess_event_field_with_invalid_values(score_type): """Test that invalid `ORAAssessEventField` raises a `ValidationError`.""" + field = mock_instance(ORAAssessEventField) - invalid_field = json.loads(field.json()) + invalid_field = json.loads(field.model_dump_json()) invalid_field["score_type"] = score_type - with pytest.raises(ValidationError, match="score_type\n unexpected value"): + with pytest.raises( + ValidationError, match="score_type\n Input should be 'PE', 'SE' or 'ST'" + ): ORAAssessEventField(**invalid_field) @@ -72,18 +72,18 @@ def test_models_edx_ora_assess_event_field_with_invalid_values(score_type, field "D0d4a647742943e3951b45d9db8a0ea1ff71ae36", ], ) -@custom_given(ORAAssessEventRubricField) def test_models_edx_ora_assess_event_rubric_field_with_invalid_problem_id_value( - content_hash, field + content_hash, ): """Test that an invalid `problem_id` value in `ProblemCheckEventField` raises a `ValidationError`. """ + field = mock_instance(ORAAssessEventRubricField) - invalid_field = json.loads(field.json()) + invalid_field = json.loads(field.model_dump_json()) invalid_field["content_hash"] = content_hash with pytest.raises( - ValidationError, match="content_hash\n string does not match regex" + ValidationError, match="content_hash\n String should match pattern " ): ORAAssessEventRubricField(**invalid_field) diff --git a/tests/models/edx/open_response_assessment/test_statements.py b/tests/models/edx/open_response_assessment/test_statements.py index dc03c7b74..f4bc19d95 100644 --- a/tests/models/edx/open_response_assessment/test_statements.py +++ b/tests/models/edx/open_response_assessment/test_statements.py @@ -3,7 +3,6 @@ import json import pytest -from hypothesis import strategies as st from ralph.models.edx.open_response_assessment.statements import ( ORACreateSubmission, @@ -19,7 +18,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -37,34 +36,30 @@ ORAStudentTrainingAssessExample, ], ) -@custom_given(st.data()) -def test_models_edx_ora_selectors_with_valid_statements(class_, data): +def test_models_edx_ora_selectors_with_valid_statements(class_): """Test given a valid open response assessment edX statement the `get_first_model` selector method should return the expected model. """ - - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(ORAGetPeerSubmission) -def test_models_edx_ora_get_peer_submission_with_valid_statement(statement): +def test_models_edx_ora_get_peer_submission_with_valid_statement(): """Test that a `openassessmentblock.get_peer_submission` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORAGetPeerSubmission) assert statement.event_type == "openassessmentblock.get_peer_submission" assert statement.page == "x_module" -@custom_given(ORAGetSubmissionForStaffGrading) -def test_models_edx_ora_get_submission_for_staff_grading_with_valid_statement( - statement, -): +def test_models_edx_ora_get_submission_for_staff_grading_with_valid_statement(): """Test that a `openassessmentblock.get_submission_for_staff_grading` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORAGetSubmissionForStaffGrading) assert ( statement.event_type == "openassessmentblock.get_submission_for_staff_grading" @@ -72,81 +67,81 @@ def test_models_edx_ora_get_submission_for_staff_grading_with_valid_statement( assert statement.page == "x_module" -@custom_given(ORAPeerAssess) -def test_models_edx_ora_peer_assess_with_valid_statement(statement): +def test_models_edx_ora_peer_assess_with_valid_statement(): """Test that a `openassessmentblock.peer_assess` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORAPeerAssess) assert statement.event_type == "openassessmentblock.peer_assess" assert statement.page == "x_module" -@custom_given(ORASelfAssess) -def test_models_edx_ora_self_assess_with_valid_statement(statement): +def test_models_edx_ora_self_assess_with_valid_statement(): """Test that a `openassessmentblock.self_assess` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORASelfAssess) assert statement.event_type == "openassessmentblock.self_assess" assert statement.page == "x_module" -@custom_given(ORAStaffAssess) -def test_models_edx_ora_staff_assess_with_valid_statement(statement): +def test_models_edx_ora_staff_assess_with_valid_statement(): """Test that a `openassessmentblock.staff_assess` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORAStaffAssess) assert statement.event_type == "openassessmentblock.staff_assess" assert statement.page == "x_module" -@custom_given(ORASubmitFeedbackOnAssessments) -def test_models_edx_ora_submit_feedback_on_assessments_with_valid_statement(statement): +def test_models_edx_ora_submit_feedback_on_assessments_with_valid_statement(): """Test that a `openassessmentblock.submit_feedback_on_assessments` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORASubmitFeedbackOnAssessments) assert statement.event_type == "openassessmentblock.submit_feedback_on_assessments" assert statement.page == "x_module" -@custom_given(ORACreateSubmission) -def test_models_edx_ora_create_submission_with_valid_statement(statement): +def test_models_edx_ora_create_submission_with_valid_statement(): """Test that a `openassessmentblock.create_submission` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORACreateSubmission) assert statement.event_type == "openassessmentblock.create_submission" assert statement.page == "x_module" -@custom_given(ORASaveSubmission) -def test_models_edx_ora_save_submission_with_valid_statement(statement): +def test_models_edx_ora_save_submission_with_valid_statement(): """Test that a `openassessmentblock.save_submission` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORASaveSubmission) assert statement.event_type == "openassessmentblock.save_submission" assert statement.page == "x_module" -@custom_given(ORAStudentTrainingAssessExample) -def test_models_edx_ora_student_training_assess_example_with_valid_statement(statement): +def test_models_edx_ora_student_training_assess_example_with_valid_statement(): """Test that a `openassessment.student_training_assess_example` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORAStudentTrainingAssessExample) assert statement.event_type == "openassessment.student_training_assess_example" assert statement.page == "x_module" -@custom_given(ORAUploadFile) -def test_models_edx_ora_upload_file_example_with_valid_statement(statement): +def test_models_edx_ora_upload_file_example_with_valid_statement(): """Test that a `openassessment.upload_file` statement has the expected `event_type` and `page` fields. """ + statement = mock_instance(ORAUploadFile) assert statement.event_type == "openassessment.upload_file" assert statement.name == "openassessment.upload_file" diff --git a/tests/models/edx/peer_instruction/test_events.py b/tests/models/edx/peer_instruction/test_events.py index 8c9cbe0f3..eea6bebae 100644 --- a/tests/models/edx/peer_instruction/test_events.py +++ b/tests/models/edx/peer_instruction/test_events.py @@ -3,30 +3,31 @@ import json import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.peer_instruction.fields.events import PeerInstructionEventField -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(PeerInstructionEventField) -def test_models_edx_peer_instruction_event_field_with_valid_field(field): +def test_models_edx_peer_instruction_event_field_with_valid_field(): """Test that a valid `PeerInstructionEventField` does not raise a `ValidationError`. """ + field = mock_instance(PeerInstructionEventField) assert len(field.rationale) <= 12500 -@custom_given(PeerInstructionEventField) -def test_models_edx_peer_instruction_event_field_with_invalid_rationale(field): +def test_models_edx_peer_instruction_event_field_with_invalid_rationale(): """Test that a valid `PeerInstructionEventField` does not raise a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(PeerInstructionEventField) + + invalid_field = json.loads(field.model_dump_json()) invalid_field["rationale"] = "x" * 12501 with pytest.raises( ValidationError, - match="rationale\n ensure this value has at most 12500 characters", + match="rationale\n String should have at most 12500 characters", ): PeerInstructionEventField(**invalid_field) diff --git a/tests/models/edx/peer_instruction/test_statements.py b/tests/models/edx/peer_instruction/test_statements.py index 3c2841573..f3cd799cc 100644 --- a/tests/models/edx/peer_instruction/test_statements.py +++ b/tests/models/edx/peer_instruction/test_statements.py @@ -3,7 +3,6 @@ import json import pytest -from hypothesis import strategies as st from ralph.models.edx.peer_instruction.statements import ( PeerInstructionAccessed, @@ -12,7 +11,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -23,44 +22,37 @@ PeerInstructionRevisedSubmitted, ], ) -@custom_given(st.data()) -def test_models_edx_peer_instruction_selectors_with_valid_statements(class_, data): +def test_models_edx_peer_instruction_selectors_with_valid_statements(class_): """Test given a valid peer_instruction edX statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(PeerInstructionAccessed) -def test_models_edx_peer_instruction_accessed_with_valid_statement( - statement, -): +def test_models_edx_peer_instruction_accessed_with_valid_statement(): """Test that a `ubc.peer_instruction.accessed` statement has the expected `event_type`. """ + statement = mock_instance(PeerInstructionAccessed) assert statement.event_type == "ubc.peer_instruction.accessed" assert statement.name == "ubc.peer_instruction.accessed" -@custom_given(PeerInstructionOriginalSubmitted) -def test_models_edx_peer_instruction_original_submitted_with_valid_statement( - statement, -): +def test_models_edx_peer_instruction_original_submitted_with_valid_statement(): """Test that a `ubc.peer_instruction.original_submitted` statement has the expected `event_type`. """ + statement = mock_instance(PeerInstructionOriginalSubmitted) assert statement.event_type == "ubc.peer_instruction.original_submitted" assert statement.name == "ubc.peer_instruction.original_submitted" -@custom_given(PeerInstructionRevisedSubmitted) -def test_models_edx_peer_instruction_revised_submitted_with_valid_statement( - statement, -): +def test_models_edx_peer_instruction_revised_submitted_with_valid_statement(): """Test that a `ubc.peer_instruction.revised_submitted` statement has the expected `event_type`. """ + statement = mock_instance(PeerInstructionRevisedSubmitted) assert statement.event_type == "ubc.peer_instruction.revised_submitted" assert statement.name == "ubc.peer_instruction.revised_submitted" diff --git a/tests/models/edx/problem_interaction/test_events.py b/tests/models/edx/problem_interaction/test_events.py index 1eef264fc..b240e7743 100644 --- a/tests/models/edx/problem_interaction/test_events.py +++ b/tests/models/edx/problem_interaction/test_events.py @@ -4,7 +4,7 @@ import re import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.problem_interaction.fields.events import ( CorrectMap, @@ -19,47 +19,51 @@ SaveProblemSuccessEventField, ) -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(CorrectMap) -def test_models_edx_correct_map_with_valid_content(subfield): +def test_models_edx_correct_map_with_valid_content(): """Test that a valid `CorrectMap` does not raise a `ValidationError`.""" + subfield = mock_instance(CorrectMap) assert subfield.correctness in ("correct", "incorrect") assert subfield.hintmode in ("on_request", "always", None) @pytest.mark.parametrize("correctness", ["corect", "incorect"]) -@custom_given(CorrectMap) -def test_models_edx_correct_map_with_invalid_correctness_value(correctness, subfield): +def test_models_edx_correct_map_with_invalid_correctness_value(correctness): """Test that an invalid `correctness` value in `CorrectMap` raises a `ValidationError`. """ - invalid_subfield = json.loads(subfield.json()) + subfield = mock_instance(CorrectMap) + invalid_subfield = json.loads(subfield.model_dump_json()) invalid_subfield["correctness"] = correctness - with pytest.raises(ValidationError, match="correctness\n unexpected value"): + with pytest.raises( + ValidationError, match="correctness\n Input should be 'correct' or 'incorrect'" + ): CorrectMap(**invalid_subfield) @pytest.mark.parametrize("hintmode", ["onrequest", "alway"]) -@custom_given(CorrectMap) -def test_models_edx_correct_map_with_invalid_hintmode_value(hintmode, subfield): +def test_models_edx_correct_map_with_invalid_hintmode_value(hintmode): """Test that an invalid `hintmode` value in `CorrectMap` raises a `ValidationError`. """ - invalid_subfield = json.loads(subfield.json()) + subfield = mock_instance(CorrectMap) + invalid_subfield = json.loads(subfield.model_dump_json()) invalid_subfield["hintmode"] = hintmode - with pytest.raises(ValidationError, match="hintmode\n unexpected value"): + with pytest.raises( + ValidationError, match="hintmode\n Input should be 'on_request' or 'always'" + ): CorrectMap(**invalid_subfield) -@custom_given(EdxProblemHintFeedbackDisplayedEventField) -def test_models_edx_problem_hint_feedback_displayed_event_field_with_valid_field(field): +def test_models_edx_problem_hint_feedback_displayed_event_field_with_valid_field(): """Test that a valid `EdxProblemHintFeedbackDisplayedEventField` does not raise a `ValidationError`. """ + field = mock_instance(EdxProblemHintFeedbackDisplayedEventField) assert field.question_type in ( "stringresponse", "choiceresponse", @@ -80,40 +84,49 @@ def test_models_edx_problem_hint_feedback_displayed_event_field_with_valid_field "optionrespons", ], ) -@custom_given(EdxProblemHintFeedbackDisplayedEventField) def test_models_edx_problem_hint_feedback_displayed_event_field_with_invalid_question_type_value( # noqa - question_type, field + question_type, ): """Test that an invalid `question_type` value in `EdxProblemHintFeedbackDisplayedEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(EdxProblemHintFeedbackDisplayedEventField) + + invalid_field = json.loads(field.model_dump_json()) invalid_field["question_type"] = question_type - with pytest.raises(ValidationError, match="question_type\n unexpected value"): + with pytest.raises( + ValidationError, + match=( + "question_type\n Input should be 'stringresponse', 'choiceresponse', " + "'multiplechoiceresponse', 'numericalresponse' or 'optionresponse'" + ), + ): EdxProblemHintFeedbackDisplayedEventField(**invalid_field) @pytest.mark.parametrize("trigger_type", ["jingle", "compund"]) -@custom_given(EdxProblemHintFeedbackDisplayedEventField) def test_models_edx_problem_hint_feedback_displayed_event_field_with_invalid_trigger_type_value( # noqa - trigger_type, field + trigger_type, ): """Test that an invalid `question_type` value in `EdxProblemHintFeedbackDisplayedEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(EdxProblemHintFeedbackDisplayedEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["trigger_type"] = trigger_type - with pytest.raises(ValidationError, match="trigger_type\n unexpected value"): + with pytest.raises( + ValidationError, match="trigger_type\n Input should be 'single' or 'compound'" + ): EdxProblemHintFeedbackDisplayedEventField(**invalid_field) -@custom_given(ProblemCheckEventField) -def test_models_edx_problem_check_event_field_with_valid_field(field): +def test_models_edx_problem_check_event_field_with_valid_field(): """Test that a valid `ProblemCheckEventField` does not raise a `ValidationError`. """ + field = mock_instance(ProblemCheckEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -151,42 +164,40 @@ def test_models_edx_problem_check_event_field_with_valid_field(field): ), ], ) -@custom_given(ProblemCheckEventField) -def test_models_edx_problem_check_event_field_with_invalid_problem_id_value( - problem_id, field -): +def test_models_edx_problem_check_event_field_with_invalid_problem_id_value(problem_id): """Test that an invalid `problem_id` value in `ProblemCheckEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemCheckEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): ProblemCheckEventField(**invalid_field) @pytest.mark.parametrize("success", ["corect", "incorect"]) -@custom_given(ProblemCheckEventField) -def test_models_edx_problem_check_event_field_with_invalid_success_value( - success, field -): +def test_models_edx_problem_check_event_field_with_invalid_success_value(success): """Test that an invalid `success` value in `ProblemCheckEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemCheckEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["success"] = success - with pytest.raises(ValidationError, match="success\n unexpected value"): + with pytest.raises( + ValidationError, match="success\n Input should be 'correct' or 'incorrect'" + ): ProblemCheckEventField(**invalid_field) -@custom_given(ProblemCheckFailEventField) -def test_models_edx_problem_check_fail_event_field_with_valid_field(field): +def test_models_edx_problem_check_fail_event_field_with_valid_field(): """Test that a valid `ProblemCheckFailEventField` does not raise a `ValidationError`. """ + field = mock_instance(ProblemCheckFailEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -224,42 +235,42 @@ def test_models_edx_problem_check_fail_event_field_with_valid_field(field): ), ], ) -@custom_given(ProblemCheckFailEventField) def test_models_edx_problem_check_fail_event_field_with_invalid_problem_id_value( - problem_id, field + problem_id, ): """Test that an invalid `problem_id` value in `ProblemCheckFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemCheckFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): ProblemCheckFailEventField(**invalid_field) @pytest.mark.parametrize("failure", ["close", "unresit"]) -@custom_given(ProblemCheckFailEventField) -def test_models_edx_problem_check_fail_event_field_with_invalid_failure_value( - failure, field -): +def test_models_edx_problem_check_fail_event_field_with_invalid_failure_value(failure): """Test that an invalid `failure` value in `ProblemCheckFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemCheckFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["failure"] = failure - with pytest.raises(ValidationError, match="failure\n unexpected value"): + with pytest.raises( + ValidationError, match="failure\n Input should be 'closed' or 'unreset'" + ): ProblemCheckFailEventField(**invalid_field) -@custom_given(ProblemRescoreEventField) -def test_models_edx_problem_rescore_event_field_with_valid_field(field): +def test_models_edx_problem_rescore_event_field_with_valid_field(): """Test that a valid `ProblemRescoreEventField` does not raise a `ValidationError`. """ + field = mock_instance(ProblemRescoreEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -297,42 +308,42 @@ def test_models_edx_problem_rescore_event_field_with_valid_field(field): ), ], ) -@custom_given(ProblemRescoreEventField) def test_models_edx_problem_rescore_event_field_with_invalid_problem_id_value( - problem_id, field + problem_id, ): """Test that an invalid `problem_id` value in `ProblemRescoreEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemRescoreEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): ProblemRescoreEventField(**invalid_field) @pytest.mark.parametrize("success", ["corect", "incorect"]) -@custom_given(ProblemRescoreEventField) -def test_models_edx_problem_rescore_event_field_with_invalid_success_value( - success, field -): +def test_models_edx_problem_rescore_event_field_with_invalid_success_value(success): """Test that an invalid `success` value in `ProblemRescoreEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemRescoreEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["success"] = success - with pytest.raises(ValidationError, match="success\n unexpected value"): + with pytest.raises( + ValidationError, match="success\n Input should be 'correct' or 'incorrect'" + ): ProblemRescoreEventField(**invalid_field) -@custom_given(ProblemRescoreFailEventField) -def test_models_edx_problem_rescore_fail_event_field_with_valid_field(field): +def test_models_edx_problem_rescore_fail_event_field_with_valid_field(): """Test that a valid `ProblemRescoreFailEventField` does not raise a `ValidationError`. """ + field = mock_instance(ProblemRescoreFailEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -370,42 +381,44 @@ def test_models_edx_problem_rescore_fail_event_field_with_valid_field(field): ), ], ) -@custom_given(ProblemRescoreFailEventField) def test_models_edx_problem_rescore_fail_event_field_with_invalid_problem_id_value( - problem_id, field + problem_id, ): """Test that an invalid `problem_id` value in `ProblemRescoreFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemRescoreFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): ProblemRescoreFailEventField(**invalid_field) @pytest.mark.parametrize("failure", ["close", "unresit"]) -@custom_given(ProblemRescoreFailEventField) def test_models_edx_problem_rescore_fail_event_field_with_invalid_failure_value( - failure, field + failure, ): """Test that an invalid `failure` value in `ProblemRescoreFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ProblemRescoreFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["failure"] = failure - with pytest.raises(ValidationError, match="failure\n unexpected value"): + with pytest.raises( + ValidationError, match="failure\n Input should be 'closed' or 'unreset'" + ): ProblemRescoreFailEventField(**invalid_field) -@custom_given(ResetProblemEventField) -def test_models_edx_reset_problem_event_field_with_valid_field(field): +def test_models_edx_reset_problem_event_field_with_valid_field(): """Test that a valid `ResetProblemEventField` does not raise a `ValidationError`. """ + field = mock_instance(ResetProblemEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -442,27 +455,25 @@ def test_models_edx_reset_problem_event_field_with_valid_field(field): ), ], ) -@custom_given(ResetProblemEventField) -def test_models_edx_reset_problem_event_field_with_invalid_problem_id_value( - problem_id, field -): +def test_models_edx_reset_problem_event_field_with_invalid_problem_id_value(problem_id): """Test that an invalid `problem_id` value in `ResetProblemEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ResetProblemEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): ResetProblemEventField(**invalid_field) -@custom_given(ResetProblemFailEventField) -def test_models_edx_reset_problem_fail_event_field_with_valid_field(field): +def test_models_edx_reset_problem_fail_event_field_with_valid_field(): """Test that a valid `ResetProblemFailEventField` does not raise a `ValidationError`. """ + field = mock_instance(ResetProblemFailEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -500,42 +511,42 @@ def test_models_edx_reset_problem_fail_event_field_with_valid_field(field): ), ], ) -@custom_given(ResetProblemFailEventField) def test_models_edx_reset_problem_fail_event_field_with_invalid_problem_id_value( - problem_id, field + problem_id, ): """Test that an invalid `problem_id` value in `ResetProblemFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ResetProblemFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): ResetProblemFailEventField(**invalid_field) @pytest.mark.parametrize("failure", ["close", "not_close"]) -@custom_given(ResetProblemFailEventField) -def test_models_edx_reset_problem_fail_event_field_with_invalid_failure_value( - failure, field -): +def test_models_edx_reset_problem_fail_event_field_with_invalid_failure_value(failure): """Test that an invalid `failure` value in `ResetProblemFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(ResetProblemFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["failure"] = failure - with pytest.raises(ValidationError, match="failure\n unexpected value"): + with pytest.raises( + ValidationError, match="failure\n Input should be 'closed' or 'not_done'" + ): ResetProblemFailEventField(**invalid_field) -@custom_given(SaveProblemFailEventField) -def test_models_edx_save_problem_fail_event_field_with_valid_field(field): +def test_models_edx_save_problem_fail_event_field_with_valid_field(): """Test that a valid `SaveProblemFailEventField` does not raise a `ValidationError`. """ + field = mock_instance(SaveProblemFailEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -573,42 +584,42 @@ def test_models_edx_save_problem_fail_event_field_with_valid_field(field): ), ], ) -@custom_given(SaveProblemFailEventField) def test_models_edx_save_problem_fail_event_field_with_invalid_problem_id_value( - problem_id, field + problem_id, ): """Test that an invalid `problem_id` value in `SaveProblemFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(SaveProblemFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): SaveProblemFailEventField(**invalid_field) @pytest.mark.parametrize("failure", ["close", "doned"]) -@custom_given(SaveProblemFailEventField) -def test_models_edx_save_problem_fail_event_field_with_invalid_failure_value( - failure, field -): +def test_models_edx_save_problem_fail_event_field_with_invalid_failure_value(failure): """Test that an invalid `failure` value in `SaveProblemFailEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(SaveProblemFailEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["failure"] = failure - with pytest.raises(ValidationError, match="failure\n unexpected value"): + with pytest.raises( + ValidationError, match="failure\n Input should be 'closed' or 'done'" + ): SaveProblemFailEventField(**invalid_field) -@custom_given(SaveProblemSuccessEventField) -def test_models_edx_save_problem_success_event_field_with_valid_field(field): +def test_models_edx_save_problem_success_event_field_with_valid_field(): """Test that a valid `SaveProblemFailEventField` does not raise a `ValidationError`. """ + field = mock_instance(SaveProblemSuccessEventField) assert re.match( ( r"^block-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]" @@ -645,17 +656,17 @@ def test_models_edx_save_problem_success_event_field_with_valid_field(field): ), ], ) -@custom_given(SaveProblemSuccessEventField) def test_models_edx_save_problem_success_event_field_with_invalid_problem_id_value( - problem_id, field + problem_id, ): """Test that an invalid `problem_id` value in `SaveProblemSuccessEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(SaveProblemSuccessEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["problem_id"] = problem_id with pytest.raises( - ValidationError, match="problem_id\n string does not match regex" + ValidationError, match="problem_id\n String should match pattern" ): SaveProblemSuccessEventField(**invalid_field) diff --git a/tests/models/edx/problem_interaction/test_statements.py b/tests/models/edx/problem_interaction/test_statements.py index 0dbb53e19..e9f027cee 100644 --- a/tests/models/edx/problem_interaction/test_statements.py +++ b/tests/models/edx/problem_interaction/test_statements.py @@ -3,7 +3,6 @@ import json import pytest -from hypothesis import strategies as st from ralph.models.edx.problem_interaction.statements import ( EdxProblemHintDemandhintDisplayed, @@ -25,7 +24,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -49,159 +48,154 @@ UIProblemShow, ], ) -@custom_given(st.data()) -def test_models_edx_edx_problem_interaction_selectors_with_valid_statements( - class_, data -): +def test_models_edx_edx_problem_interaction_selectors_with_valid_statements(class_): """Test given a valid problem interaction edX statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(EdxProblemHintDemandhintDisplayed) -def test_models_edx_edx_problem_hint_demandhint_displayed_with_valid_statement( - statement, -): +def test_models_edx_edx_problem_hint_demandhint_displayed_with_valid_statement(): """Test that a `edx.problem.hint.demandhint_displayed` statement has the expected `event_type` and `page`. """ + statement = mock_instance(EdxProblemHintDemandhintDisplayed) assert statement.event_type == "edx.problem.hint.demandhint_displayed" assert statement.page == "x_module" -@custom_given(EdxProblemHintFeedbackDisplayed) -def test_models_edx_edx_problem_hint_feedback_displayed_with_valid_statement(statement): +def test_models_edx_edx_problem_hint_feedback_displayed_with_valid_statement(): """Test that a `edx.problem.hint.feedback_displayed` statement has the expected `event_type` and `page`. """ + statement = mock_instance(EdxProblemHintFeedbackDisplayed) assert statement.event_type == "edx.problem.hint.feedback_displayed" assert statement.page == "x_module" -@custom_given(UIProblemCheck) -def test_models_edx_ui_problem_check_with_valid_statement(statement): +def test_models_edx_ui_problem_check_with_valid_statement(): """Test that a `problem_check` browser statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIProblemCheck) assert statement.event_type == "problem_check" assert statement.name == "problem_check" -@custom_given(ProblemCheck) -def test_models_edx_problem_check_with_valid_statement(statement): +def test_models_edx_problem_check_with_valid_statement(): """Test that a `problem_check` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ProblemCheck) assert statement.event_type == "problem_check" assert statement.page == "x_module" -@custom_given(ProblemCheckFail) -def test_models_edx_problem_check_fail_with_valid_statement(statement): +def test_models_edx_problem_check_fail_with_valid_statement(): """Test that a `problem_check_fail` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ProblemCheckFail) assert statement.event_type == "problem_check_fail" assert statement.page == "x_module" -@custom_given(UIProblemGraded) -def test_models_edx_ui_problem_graded_with_valid_statement(statement): +def test_models_edx_ui_problem_graded_with_valid_statement(): """Test that a `problem_graded` browser statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIProblemGraded) assert statement.event_type == "problem_graded" assert statement.name == "problem_graded" -@custom_given(ProblemRescore) -def test_models_edx_problem_rescore_with_valid_statement(statement): +def test_models_edx_problem_rescore_with_valid_statement(): """Test that a `problem_rescore` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ProblemRescore) assert statement.event_type == "problem_rescore" assert statement.page == "x_module" -@custom_given(ProblemRescoreFail) -def test_models_edx_problem_rescore_fail_with_valid_statement(statement): +def test_models_edx_problem_rescore_fail_with_valid_statement(): """Test that a `problem_rescore` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ProblemRescoreFail) assert statement.event_type == "problem_rescore_fail" assert statement.page == "x_module" -@custom_given(UIProblemReset) -def test_models_edx_ui_problem_reset_with_valid_statement(statement): +def test_models_edx_ui_problem_reset_with_valid_statement(): """Test that a `problem_reset` browser statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIProblemReset) assert statement.event_type == "problem_reset" assert statement.name == "problem_reset" -@custom_given(UIProblemSave) -def test_models_edx_ui_problem_save_with_valid_statement(statement): +def test_models_edx_ui_problem_save_with_valid_statement(): """Test that a `problem_save` browser statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIProblemSave) assert statement.event_type == "problem_save" assert statement.name == "problem_save" -@custom_given(UIProblemShow) -def test_models_edx_ui_problem_show_with_valid_statement(statement): +def test_models_edx_ui_problem_show_with_valid_statement(): """Test that a `problem_show` browser statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIProblemShow) assert statement.event_type == "problem_show" assert statement.name == "problem_show" -@custom_given(ResetProblem) -def test_models_edx_reset_problem_with_valid_statement(statement): +def test_models_edx_reset_problem_with_valid_statement(): """Test that a `reset_problem` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ResetProblem) assert statement.event_type == "reset_problem" assert statement.page == "x_module" -@custom_given(ResetProblemFail) -def test_models_edx_reset_problem_fail_with_valid_statement(statement): +def test_models_edx_reset_problem_fail_with_valid_statement(): """Test that a `reset_problem_fail` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ResetProblemFail) assert statement.event_type == "reset_problem_fail" assert statement.page == "x_module" -@custom_given(SaveProblemFail) -def test_models_edx_save_problem_fail_with_valid_statement(statement): +def test_models_edx_save_problem_fail_with_valid_statement(): """Test that a `save_problem_fail` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(SaveProblemFail) assert statement.event_type == "save_problem_fail" assert statement.page == "x_module" -@custom_given(SaveProblemSuccess) -def test_models_edx_save_problem_success_with_valid_statement(statement): +def test_models_edx_save_problem_success_with_valid_statement(): """Test that a `save_problem_success` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(SaveProblemSuccess) assert statement.event_type == "save_problem_success" assert statement.page == "x_module" -@custom_given(ShowAnswer) -def test_models_edx_show_answer_with_valid_statement(statement): +def test_models_edx_show_answer_with_valid_statement(): """Test that a `showanswer` server statement has the expected `event_type` and `page`. """ + statement = mock_instance(ShowAnswer) assert statement.event_type == "showanswer" assert statement.page == "x_module" diff --git a/tests/models/edx/test_base.py b/tests/models/edx/test_base.py index 8f3a65de8..bbff5c59d 100644 --- a/tests/models/edx/test_base.py +++ b/tests/models/edx/test_base.py @@ -4,16 +4,17 @@ import re import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.base import BaseEdxModel -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(BaseEdxModel) -def test_models_edx_base_edx_model_with_valid_statement(statement): +def test_models_edx_base_edx_model_with_valid_statement(): """Test that a valid base `Edx` statement does not raise a `ValidationError`.""" + statement = mock_instance(BaseEdxModel) + assert len(statement.username) == 0 or (len(statement.username) in range(2, 31, 1)) assert ( re.match(r"^course-v1:.+\+.+\+.+$", statement.context.course_id) @@ -22,31 +23,21 @@ def test_models_edx_base_edx_model_with_valid_statement(statement): @pytest.mark.parametrize( - "course_id,error", + "course_id", [ - ( - "course-v1:+course+not_empty", - "course_id\n string does not match regex", - ), - ( - "course-v1:org", - "course_id\n string does not match regex", - ), - ( - "course-v1:org+course", - "course_id\n string does not match regex", - ), - ( - "course-v1:org+course+", - "course_id\n string does not match regex", - ), + "course-v1:+course+not_empty", + "course-v1:org", + "course-v1:org+course", + "course-v1:org+course+", ], ) -@custom_given(BaseEdxModel) -def test_models_edx_base_edx_model_with_invalid_statement(course_id, error, statement): +def test_models_edx_base_edx_model_with_invalid_statement(course_id): """Test that an invalid base `Edx` statement raises a `ValidationError`.""" - invalid_statement = json.loads(statement.json()) + statement = mock_instance(BaseEdxModel) + invalid_statement = json.loads(statement.model_dump_json()) invalid_statement["context"]["course_id"] = course_id + error = "String should match pattern" + with pytest.raises(ValidationError, match=error): BaseEdxModel(**invalid_statement) diff --git a/tests/models/edx/test_browser.py b/tests/models/edx/test_browser.py index 06b2a7bed..2dc931fa7 100644 --- a/tests/models/edx/test_browser.py +++ b/tests/models/edx/test_browser.py @@ -4,16 +4,16 @@ import re import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.browser import BaseBrowserModel -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(BaseBrowserModel) -def test_models_edx_base_browser_model_with_valid_statement(statement): +def test_models_edx_base_browser_model_with_valid_statement(): """Test that a valid base browser statement does not raise a `ValidationError`.""" + statement = mock_instance(BaseBrowserModel) assert re.match(r"^[a-f0-9]{32}$", statement.session) or statement.session == "" @@ -21,19 +21,17 @@ def test_models_edx_base_browser_model_with_valid_statement(statement): "session,error", [ # less than 32 characters - ("abcdef0123456789", "session\n string does not match regex"), + ("abcdef0123456789", "String should match pattern"), # more than 32 characters - ("abcdef0123456789abcdef0123456789abcdef", "string does not match regex"), + ("abcdef0123456789abcdef0123456789abcdef", "String should match pattern"), # with excluded characters - ("abcdef0123456789_abcdef012345678", "string does not match regex"), + ("abcdef0123456789_abcdef012345678", "String should match pattern"), ], ) -@custom_given(BaseBrowserModel) -def test_models_edx_base_browser_model_with_invalid_statement( - session, error, statement -): +def test_models_edx_base_browser_model_with_invalid_statement(session, error): """Test that an invalid base browser statement raises a `ValidationError`.""" - invalid_statement = json.loads(statement.json()) + statement = mock_instance(BaseBrowserModel) + invalid_statement = json.loads(statement.model_dump_json()) invalid_statement["session"] = session with pytest.raises(ValidationError, match=error): diff --git a/tests/models/edx/test_enrollment.py b/tests/models/edx/test_enrollment.py index 53c1af26c..0af30e518 100644 --- a/tests/models/edx/test_enrollment.py +++ b/tests/models/edx/test_enrollment.py @@ -3,7 +3,6 @@ import json import pytest -from hypothesis import strategies as st from ralph.models.edx.enrollment.statements import ( EdxCourseEnrollmentActivated, @@ -14,7 +13,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -27,66 +26,55 @@ UIEdxCourseEnrollmentUpgradeClicked, ], ) -@custom_given(st.data()) -def test_models_edx_edx_course_enrollment_selectors_with_valid_statements(class_, data): +def test_models_edx_edx_course_enrollment_selectors_with_valid_statements(class_): """Test given a valid course enrollment edX statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(EdxCourseEnrollmentActivated) -def test_models_edx_edx_course_enrollment_activated_with_valid_statement( - statement, -): +def test_models_edx_edx_course_enrollment_activated_with_valid_statement(): """Test that a `edx.course.enrollment.activated` statement has the expected `event_type` and `name`. """ + statement = mock_instance(EdxCourseEnrollmentActivated) assert statement.event_type == "edx.course.enrollment.activated" assert statement.name == "edx.course.enrollment.activated" -@custom_given(EdxCourseEnrollmentDeactivated) -def test_models_edx_edx_course_enrollment_deactivated_with_valid_statement( - statement, -): +def test_models_edx_edx_course_enrollment_deactivated_with_valid_statement(): """Test that a `edx.course.enrollment.deactivated` statement has the expected `event_type` and `name`. """ + statement = mock_instance(EdxCourseEnrollmentDeactivated) assert statement.event_type == "edx.course.enrollment.deactivated" assert statement.name == "edx.course.enrollment.deactivated" -@custom_given(EdxCourseEnrollmentModeChanged) -def test_models_edx_edx_course_enrollment_mode_changed_with_valid_statement( - statement, -): +def test_models_edx_edx_course_enrollment_mode_changed_with_valid_statement(): """Test that a `edx.course.enrollment.mode_changed` statement has the expected `event_type` and `name`. """ + statement = mock_instance(EdxCourseEnrollmentModeChanged) assert statement.event_type == "edx.course.enrollment.mode_changed" assert statement.name == "edx.course.enrollment.mode_changed" -@custom_given(UIEdxCourseEnrollmentUpgradeClicked) -def test_models_edx_ui_edx_course_enrollment_upgrade_clicked_with_valid_statement( - statement, -): +def test_models_edx_ui_edx_course_enrollment_upgrade_clicked_with_valid_statement(): """Test that a `edx.course.enrollment.upgrade_clicked` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIEdxCourseEnrollmentUpgradeClicked) assert statement.event_type == "edx.course.enrollment.upgrade_clicked" assert statement.name == "edx.course.enrollment.upgrade_clicked" -@custom_given(EdxCourseEnrollmentUpgradeSucceeded) -def test_models_edx_edx_course_enrollment_upgrade_succeeded_with_valid_statement( - statement, -): +def test_models_edx_edx_course_enrollment_upgrade_succeeded_with_valid_statement(): """Test that a `edx.course.enrollment.upgrade.succeeded` statement has the expected `event_type` and `name`. """ + statement = mock_instance(EdxCourseEnrollmentUpgradeSucceeded) assert statement.event_type == "edx.course.enrollment.upgrade.succeeded" assert statement.name == "edx.course.enrollment.upgrade.succeeded" diff --git a/tests/models/edx/test_server.py b/tests/models/edx/test_server.py index ef8dbded4..d8db31c6a 100644 --- a/tests/models/edx/test_server.py +++ b/tests/models/edx/test_server.py @@ -8,15 +8,16 @@ from ralph.models.edx.server import Server from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(Server) -def test_model_selector_server_get_model_with_valid_event(event): +def test_model_selector_server_get_model_with_valid_event(): """Test given a server statement, the get_model method should return the corresponding model. """ - event = json.loads(event.json()) + event = mock_instance(Server) + + event = json.loads(event.model_dump_json()) assert ModelSelector(module="ralph.models.edx").get_first_model(event) is Server diff --git a/tests/models/edx/textbook_interaction/test_events.py b/tests/models/edx/textbook_interaction/test_events.py index 024fff7ac..01e8a4166 100644 --- a/tests/models/edx/textbook_interaction/test_events.py +++ b/tests/models/edx/textbook_interaction/test_events.py @@ -4,21 +4,21 @@ import re import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.textbook_interaction.fields.events import ( TextbookInteractionBaseEventField, TextbookPdfChapterNavigatedEventField, ) -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(TextbookInteractionBaseEventField) -def test_fields_edx_textbook_interaction_base_event_field_with_valid_content(field): +def test_fields_edx_textbook_interaction_base_event_field_with_valid_content(): """Test that a valid `TextbookInteractionBaseEventField` does not raise a `ValidationError`. """ + field = mock_instance(TextbookInteractionBaseEventField) assert re.match( r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$", @@ -61,28 +61,24 @@ def test_fields_edx_textbook_interaction_base_event_field_with_valid_content(fie ), ), ) -@custom_given(TextbookInteractionBaseEventField) -def test_fields_edx_textbook_interaction_base_event_field_with_invalid_content( - chapter, field -): +def test_fields_edx_textbook_interaction_base_event_field_with_invalid_content(chapter): """Test that an invalid `TextbookInteractionBaseEventField` raises a `ValidationError`. """ + field = mock_instance(TextbookInteractionBaseEventField) - invalid_field = json.loads(field.json()) + invalid_field = json.loads(field.model_dump_json()) invalid_field["chapter"] = chapter - with pytest.raises(ValidationError, match="chapter\n string does not match regex"): + with pytest.raises(ValidationError, match="chapter\n String should match pattern"): TextbookInteractionBaseEventField(**invalid_field) -@custom_given(TextbookPdfChapterNavigatedEventField) -def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_valid_content( - field, -): +def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_valid_content(): """Test that a valid `TextbookPdfChapterNavigatedEventField` does not raise a `ValidationError`. """ + field = mock_instance(TextbookPdfChapterNavigatedEventField) assert re.match( (r"^\/asset-v1:[^\/+]+(\/|\+)[^\/+]+(\/|\+)[^\/?]+type@asset\+block.+$"), @@ -121,16 +117,16 @@ def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_valid_conten ), ), ) -@custom_given(TextbookPdfChapterNavigatedEventField) def test_fields_edx_textbook_pdf_chapter_navigated_event_field_with_invalid_content( - chapter, field + chapter, ): """Test that an invalid `TextbookPdfChapterNavigatedEventField` raises a `ValidationError`. """ + field = mock_instance(TextbookPdfChapterNavigatedEventField) - invalid_field = json.loads(field.json()) + invalid_field = json.loads(field.model_dump_json()) invalid_field["chapter"] = chapter - with pytest.raises(ValidationError, match="chapter\n string does not match regex"): + with pytest.raises(ValidationError, match="chapter\n String should match pattern"): TextbookPdfChapterNavigatedEventField(**invalid_field) diff --git a/tests/models/edx/textbook_interaction/test_statements.py b/tests/models/edx/textbook_interaction/test_statements.py index 1218d74ce..7751462d6 100644 --- a/tests/models/edx/textbook_interaction/test_statements.py +++ b/tests/models/edx/textbook_interaction/test_statements.py @@ -3,7 +3,6 @@ import json import pytest -from hypothesis import strategies as st from ralph.models.edx.textbook_interaction.statements import ( UIBook, @@ -23,7 +22,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -45,153 +44,134 @@ UITextbookPdfZoomMenuChanged, ], ) -@custom_given(st.data()) -def test_models_edx_ui_textbook_interaction_selectors_with_valid_statements( - class_, data -): +def test_models_edx_ui_textbook_interaction_selectors_with_valid_statements(class_): """Test given a valid textbook interaction edX statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(UIBook) -def test_models_edx_ui_book_with_valid_statement(statement): +def test_models_edx_ui_book_with_valid_statement(): """Test that a `book` statement has the expected `event_type` and `name`.""" + statement = mock_instance(UIBook) assert statement.event_type == "book" assert statement.name == "book" -@custom_given(UITextbookPdfThumbnailsToggled) -def test_models_edx_ui_textbook_pdf_thumbnails_toggled_with_valid_statement(statement): +def test_models_edx_ui_textbook_pdf_thumbnails_toggled_with_valid_statement(): """Test that a `textbook.pdf.thumbnails.toggled` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfThumbnailsToggled) assert statement.event_type == "textbook.pdf.thumbnails.toggled" assert statement.name == "textbook.pdf.thumbnails.toggled" -@custom_given(UITextbookPdfThumbnailNavigated) -def test_models_edx_ui_textbook_pdf_thumbnail_navigated_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_thumbnail_navigated_with_valid_statement(): """Test that a `textbook.pdf.thumbnail.navigated` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfThumbnailNavigated) assert statement.event_type == "textbook.pdf.thumbnail.navigated" assert statement.name == "textbook.pdf.thumbnail.navigated" -@custom_given(UITextbookPdfOutlineToggled) -def test_models_edx_ui_textbook_pdf_outline_toggled_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_outline_toggled_with_valid_statement(): """Test that a `textbook.pdf.outline.toggled` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfOutlineToggled) assert statement.event_type == "textbook.pdf.outline.toggled" assert statement.name == "textbook.pdf.outline.toggled" -@custom_given(UITextbookPdfChapterNavigated) -def test_models_edx_ui_textbook_pdf_chapter_navigated_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_chapter_navigated_with_valid_statement(): """Test that a `textbook.pdf.chapter.navigated` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfChapterNavigated) assert statement.event_type == "textbook.pdf.chapter.navigated" assert statement.name == "textbook.pdf.chapter.navigated" -@custom_given(UITextbookPdfPageNavigated) -def test_models_edx_ui_textbook_pdf_page_navigated_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_page_navigated_with_valid_statement(): """Test that a `textbook.pdf.page.navigated` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfPageNavigated) assert statement.event_type == "textbook.pdf.page.navigated" assert statement.name == "textbook.pdf.page.navigated" -@custom_given(UITextbookPdfZoomButtonsChanged) -def test_models_edx_ui_textbook_pdf_zoom_buttons_changed_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_zoom_buttons_changed_with_valid_statement(): """Test that a `textbook.pdf.zoom.buttons.changed` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfZoomButtonsChanged) assert statement.event_type == "textbook.pdf.zoom.buttons.changed" assert statement.name == "textbook.pdf.zoom.buttons.changed" -@custom_given(UITextbookPdfZoomMenuChanged) -def test_models_edx_ui_textbook_pdf_zoom_menu_changed_with_valid_statement(statement): +def test_models_edx_ui_textbook_pdf_zoom_menu_changed_with_valid_statement(): """Test that a `textbook.pdf.zoom.menu.changed` has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfZoomMenuChanged) assert statement.event_type == "textbook.pdf.zoom.menu.changed" assert statement.name == "textbook.pdf.zoom.menu.changed" -@custom_given(UITextbookPdfDisplayScaled) -def test_models_edx_ui_textbook_pdf_display_scaled_with_valid_statement(statement): +def test_models_edx_ui_textbook_pdf_display_scaled_with_valid_statement(): """Test that a `textbook.pdf.display.scaled` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfDisplayScaled) assert statement.event_type == "textbook.pdf.display.scaled" assert statement.name == "textbook.pdf.display.scaled" -@custom_given(UITextbookPdfPageScrolled) -def test_models_edx_ui_textbook_pdf_page_scrolled_with_valid_statement(statement): +def test_models_edx_ui_textbook_pdf_page_scrolled_with_valid_statement(): """Test that a `textbook.pdf.page.scrolled` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfPageScrolled) assert statement.event_type == "textbook.pdf.page.scrolled" assert statement.name == "textbook.pdf.page.scrolled" -@custom_given(UITextbookPdfSearchExecuted) -def test_models_edx_ui_textbook_pdf_search_executed_with_valid_statement(statement): +def test_models_edx_ui_textbook_pdf_search_executed_with_valid_statement(): """Test that a `textbook.pdf.search.executed` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfSearchExecuted) assert statement.event_type == "textbook.pdf.search.executed" assert statement.name == "textbook.pdf.search.executed" -@custom_given(UITextbookPdfSearchNavigatedNext) -def test_models_edx_ui_textbook_pdf_search_navigated_next_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_search_navigated_next_with_valid_statement(): """Test that a `textbook.pdf.search.navigatednext` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfSearchNavigatedNext) assert statement.event_type == "textbook.pdf.search.navigatednext" assert statement.name == "textbook.pdf.search.navigatednext" -@custom_given(UITextbookPdfSearchHighlightToggled) -def test_models_edx_ui_textbook_pdf_search_highlight_toggled_with_valid_statement( - statement, -): +def test_models_edx_ui_textbook_pdf_search_highlight_toggled_with_valid_statement(): """Test that a `textbook.pdf.search.highlight.toggled` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfSearchHighlightToggled) assert statement.event_type == "textbook.pdf.search.highlight.toggled" assert statement.name == "textbook.pdf.search.highlight.toggled" -@custom_given(UITextbookPdfSearchCaseSensitivityToggled) -def test_models_edx_ui_textbook_pdf_search_case_sensitivity_toggled_with_valid_statement( # noqa - statement, -): +def test_models_edx_ui_textbook_pdf_search_case_sensitivity_toggled_with_valid_statement(): # noqa """Test that a `textbook.pdf.searchcasesensitivity.toggled` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UITextbookPdfSearchCaseSensitivityToggled) assert statement.event_type == "textbook.pdf.searchcasesensitivity.toggled" assert statement.name == "textbook.pdf.searchcasesensitivity.toggled" diff --git a/tests/models/edx/video/test_events.py b/tests/models/edx/video/test_events.py index 166589e72..cdc32f84a 100644 --- a/tests/models/edx/video/test_events.py +++ b/tests/models/edx/video/test_events.py @@ -3,18 +3,18 @@ import json import pytest -from pydantic.error_wrappers import ValidationError +from pydantic import ValidationError from ralph.models.edx.video.fields.events import SpeedChangeVideoEventField -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance -@custom_given(SpeedChangeVideoEventField) -def test_models_edx_speed_change_video_event_field_with_valid_field(field): +def test_models_edx_speed_change_video_event_field_with_valid_field(): """Test that a valid `SpeedChangeVideoEventField` does not raise a `ValidationError`. """ + field = mock_instance(SpeedChangeVideoEventField) assert field.old_speed in ["0.75", "1.0", "1.25", "1.50", "2.0"] assert field.new_speed in ["0.75", "1.0", "1.25", "1.50", "2.0"] @@ -23,17 +23,20 @@ def test_models_edx_speed_change_video_event_field_with_valid_field(field): "old_speed", ["0,75", "1", "-1.0", "1.30"], ) -@custom_given(SpeedChangeVideoEventField) def test_models_edx_speed_change_video_event_field_with_invalid_old_speed_value( - old_speed, field + old_speed, ): """Test that an invalid `old_speed` value in `SpeedChangeVideoEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(SpeedChangeVideoEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["old_speed"] = old_speed - with pytest.raises(ValidationError, match="old_speed\n unexpected value"): + with pytest.raises( + ValidationError, + match="old_speed\n Input should be '0.75', '1.0', '1.25', '1.50' or '2.0'", + ): SpeedChangeVideoEventField(**invalid_field) @@ -41,15 +44,18 @@ def test_models_edx_speed_change_video_event_field_with_invalid_old_speed_value( "new_speed", ["0,75", "1", "-1.0", "1.30"], ) -@custom_given(SpeedChangeVideoEventField) def test_models_edx_speed_change_video_event_field_with_invalid_new_speed_value( - new_speed, field + new_speed, ): """Test that an invalid `new_speed` value in `SpeedChangeVideoEventField` raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_instance(SpeedChangeVideoEventField) + invalid_field = json.loads(field.model_dump_json()) invalid_field["new_speed"] = new_speed - with pytest.raises(ValidationError, match="new_speed\n unexpected value"): + with pytest.raises( + ValidationError, + match="new_speed\n Input should be '0.75', '1.0', '1.25', '1.50' or '2.0'", + ): SpeedChangeVideoEventField(**invalid_field) diff --git a/tests/models/edx/video/test_statements.py b/tests/models/edx/video/test_statements.py index e2dec9269..2f006d08d 100644 --- a/tests/models/edx/video/test_statements.py +++ b/tests/models/edx/video/test_statements.py @@ -3,7 +3,6 @@ import json import pytest -from hypothesis import strategies as st from ralph.models.edx.video.statements import ( UIHideTranscript, @@ -19,7 +18,7 @@ ) from ralph.models.selector import ModelSelector -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -37,98 +36,77 @@ UIVideoShowCCMenu, ], ) -@custom_given(st.data()) -def test_models_edx_video_selectors_with_valid_statements(class_, data): +def test_models_edx_video_selectors_with_valid_statements(class_): """Test given a valid video edX statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.edx").get_first_model(statement) assert model is class_ -@custom_given(UIPlayVideo) -def test_models_edx_ui_play_video_with_valid_statement( - statement, -): +def test_models_edx_ui_play_video_with_valid_statement(): + statement = mock_instance(UIPlayVideo) """Test that a `play_video` statement has the expected `event_type`.""" assert statement.event_type == "play_video" -@custom_given(UIPauseVideo) -def test_models_edx_ui_pause_video_with_valid_statement( - statement, -): +def test_models_edx_ui_pause_video_with_valid_statement(): + statement = mock_instance(UIPauseVideo) """Test that a `pause_video` statement has the expected `event_type`.""" assert statement.event_type == "pause_video" -@custom_given(UILoadVideo) -def test_models_edx_ui_load_video_with_valid_statement( - statement, -): +def test_models_edx_ui_load_video_with_valid_statement(): + statement = mock_instance(UILoadVideo) """Test that a `load_video` statement has the expected `event_type` and `name`.""" assert statement.event_type == "load_video" assert statement.name in {"load_video", "edx.video.loaded"} -@custom_given(UISeekVideo) -def test_models_edx_ui_seek_video_with_valid_statement( - statement, -): +def test_models_edx_ui_seek_video_with_valid_statement(): + statement = mock_instance(UISeekVideo) """Test that a `seek_video` statement has the expected `event_type`.""" assert statement.event_type == "seek_video" -@custom_given(UIStopVideo) -def test_models_edx_ui_stop_video_with_valid_statement( - statement, -): +def test_models_edx_ui_stop_video_with_valid_statement(): + statement = mock_instance(UIStopVideo) """Test that a `stop_video` statement has the expected `event_type`.""" assert statement.event_type == "stop_video" -@custom_given(UIHideTranscript) -def test_models_edx_ui_hide_transcript_with_valid_statement( - statement, -): +def test_models_edx_ui_hide_transcript_with_valid_statement(): """Test that a `hide_transcript` statement has the expected `event_type` and `name`. """ + statement = mock_instance(UIHideTranscript) assert statement.event_type == "hide_transcript" assert statement.name in {"hide_transcript", "edx.video.transcript.hidden"} -@custom_given(UIShowTranscript) -def test_models_edx_ui_show_transcript_with_valid_statement( - statement, -): +def test_models_edx_ui_show_transcript_with_valid_statement(): """Test that a `show_transcript` statement has the expected `event_type` and `name. """ + statement = mock_instance(UIShowTranscript) assert statement.event_type == "show_transcript" assert statement.name in {"show_transcript", "edx.video.transcript.shown"} -@custom_given(UISpeedChangeVideo) -def test_models_edx_ui_speed_change_video_with_valid_statement( - statement, -): +def test_models_edx_ui_speed_change_video_with_valid_statement(): """Test that a `speed_change_video` statement has the expected `event_type`.""" + statement = mock_instance(UISpeedChangeVideo) assert statement.event_type == "speed_change_video" -@custom_given(UIVideoHideCCMenu) -def test_models_edx_ui_vide_hide_cc_menu_with_valid_statement( - statement, -): +def test_models_edx_ui_vide_hide_cc_menu_with_valid_statement(): """Test that a `video_hide_cc_menu` statement has the expected `event_type`.""" + statement = mock_instance(UIVideoHideCCMenu) assert statement.event_type == "video_hide_cc_menu" -@custom_given(UIVideoShowCCMenu) -def test_models_edx_ui_video_show_cc_menu_with_valid_statement( - statement, -): +def test_models_edx_ui_video_show_cc_menu_with_valid_statement(): """Test that a `video_show_cc_menu` statement has the expected `event_type`.""" + statement = mock_instance(UIVideoShowCCMenu) assert statement.event_type == "video_show_cc_menu" diff --git a/tests/models/test_converter.py b/tests/models/test_converter.py index 2fcad9e82..e87afdaab 100644 --- a/tests/models/test_converter.py +++ b/tests/models/test_converter.py @@ -5,9 +5,7 @@ from typing import Any, Optional import pytest -from hypothesis import HealthCheck, settings -from pydantic import BaseModel -from pydantic.error_wrappers import ValidationError +from pydantic import BaseModel, ValidationError from ralph.exceptions import ( BadFormatException, @@ -24,7 +22,7 @@ from ralph.models.edx.converters.xapi.base import BaseConversionSet from ralph.models.edx.navigational.statements import UIPageClose -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance @pytest.mark.parametrize( @@ -107,16 +105,19 @@ def test_converter_convert_dict_event_with_empty_conversion_set(event): model. """ + class DummyModel(BaseModel): + pass + class DummyBaseConversionSet(BaseConversionSet): """Dummy implementation of abstract BaseConversionSet.""" - __dest__ = BaseModel + __dest__ = DummyModel def _get_conversion_items(self): """Return a set of ConversionItems used for conversion.""" return set() - assert not convert_dict_event(event, "", DummyBaseConversionSet()).dict() + assert not convert_dict_event(event, "", DummyBaseConversionSet()).model_dump() @pytest.mark.parametrize("event", [{"foo": "foo_value", "bar": "bar_value"}]) @@ -145,7 +146,7 @@ def test_converter_convert_dict_event_with_one_conversion_item( class DummyBaseModel(BaseModel): """Dummy base model with one field.""" - converted: Optional[Any] + converted: Optional[Any] = None class DummyBaseConversionSet(BaseConversionSet): """Dummy implementation of abstract BaseConversionSet.""" @@ -157,7 +158,7 @@ def _get_conversion_items(self): return {ConversionItem("converted", source, transformer)} converted = convert_dict_event(event, "", DummyBaseConversionSet()) - assert converted.dict(exclude_none=True) == expected + assert converted.model_dump(exclude_none=True) == expected @pytest.mark.parametrize("item", [ConversionItem("foo", None, lambda x: x / 0)]) @@ -328,17 +329,17 @@ def test_converter_convert_with_an_event_missing_a_conversion_set_raises_an_exce list(result) -@settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) @pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) @pytest.mark.parametrize("invalid_platform_url", ["", "not an URL"]) -@custom_given(UIPageClose) def test_converter_convert_with_invalid_arguments_raises_an_exception( - valid_uuid, invalid_platform_url, caplog, event + valid_uuid, invalid_platform_url, caplog ): """Test given invalid arguments causing the conversion to fail at the validation step, the convert method should raise a ValidationError. """ - event_str = event.json() + event = mock_instance(UIPageClose) + + event_str = event.model_dump_json() result = Converter( platform_url=invalid_platform_url, uuid_namespace=valid_uuid ).convert([event_str], ignore_errors=False, fail_on_unknown=True) @@ -350,12 +351,14 @@ def test_converter_convert_with_invalid_arguments_raises_an_exception( @pytest.mark.parametrize("ignore_errors", [True, False]) @pytest.mark.parametrize("fail_on_unknown", [True, False]) @pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) -@custom_given(UIPageClose) def test_converter_convert_with_valid_events( - ignore_errors, fail_on_unknown, valid_uuid, event + ignore_errors, fail_on_unknown, valid_uuid ): """Test given a valid event the convert method should yield it.""" - event_str = event.json() + + event = mock_instance(UIPageClose) + + event_str = event.model_dump_json() result = Converter( platform_url="https://fun-mooc.fr", uuid_namespace=valid_uuid ).convert([event_str], ignore_errors, fail_on_unknown) @@ -365,14 +368,14 @@ def test_converter_convert_with_valid_events( ) -@settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) -@custom_given(UIPageClose) @pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) -def test_converter_convert_counter(valid_uuid, caplog, event): +def test_converter_convert_counter(valid_uuid, caplog): """Test given multiple events the convert method should log the total and invalid events. """ - valid_event = event.json() + event = mock_instance(UIPageClose) + + valid_event = event.model_dump_json() invalid_event_1 = 1 invalid_event_2 = "" events = [invalid_event_1, valid_event, invalid_event_2] diff --git a/tests/models/test_validator.py b/tests/models/test_validator.py index 61a6c8fb0..a2db71d5d 100644 --- a/tests/models/test_validator.py +++ b/tests/models/test_validator.py @@ -5,7 +5,6 @@ import logging import pytest -from hypothesis import HealthCheck, settings from pydantic import ValidationError, create_model from ralph.exceptions import BadFormatException, UnknownEventException @@ -14,7 +13,7 @@ from ralph.models.selector import ModelSelector from ralph.models.validator import Validator -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_instance def test_models_validator_validate_with_no_events(caplog): @@ -120,7 +119,7 @@ def test_models_validator_validate_with_an_invalid_page_close_event_writes_an_er ) with caplog.at_level(logging.ERROR): assert not list(result) - errors = ["Input event is not a valid UIPageClose event."] + errors = ["Input event is not valid."] assert errors == [message for _, _, message in caplog.record_tuples] @@ -142,25 +141,24 @@ def test_models_validator_validate_with_invalid_page_close_event_raises_an_excep @pytest.mark.parametrize("ignore_errors", [True, False]) @pytest.mark.parametrize("fail_on_unknown", [True, False]) -@custom_given(UIPageClose) -def test_models_validator_validate_with_valid_events( - ignore_errors, fail_on_unknown, event -): +def test_models_validator_validate_with_valid_events(ignore_errors, fail_on_unknown): """Test given a valid event the validate method should yield it.""" - event_str = event.json() + event = mock_instance(UIPageClose) + + event_str = event.model_dump_json() event_dict = json.loads(event_str) validator = Validator(ModelSelector(module="ralph.models.edx")) result = validator.validate([event_str], ignore_errors, fail_on_unknown) assert json.loads(next(result)) == event_dict -@settings(suppress_health_check=(HealthCheck.function_scoped_fixture,)) -@custom_given(UIPageClose) -def test_models_validator_validate_counter(caplog, event): +def test_models_validator_validate_counter(caplog): """Test given multiple events the validate method should log the total and invalid events. """ - valid_event = event.json() + event = mock_instance(UIPageClose) + + valid_event = event.model_dump_json() invalid_event_1 = 1 invalid_event_2 = "" events = [invalid_event_1, valid_event, invalid_event_2] @@ -176,12 +174,13 @@ def test_models_validator_validate_counter(caplog, event): ) in caplog.record_tuples -@custom_given(Server) -def test_models_validator_validate_typing_cleanup(event): +def test_models_validator_validate_typing_cleanup(): """Test given a valid event with wrong field types, the validate method should fix them. """ - valid_event_str = event.json() + event = mock_instance(Server) + + valid_event_str = event.model_dump_json() valid_event = json.loads(valid_event_str) valid_event["host"] = "1" valid_event["accept_language"] = "False" @@ -190,7 +189,6 @@ def test_models_validator_validate_typing_cleanup(event): invalid_event = copy.deepcopy(valid_event) invalid_event["host"] = 1 # not string - invalid_event["accept_language"] = False # not string invalid_event["context"]["course_user_tags"] = {"foo": 1} # not string invalid_event["context"]["user_id"] = "123" # not integer invalid_event_str = json.dumps(invalid_event) @@ -204,7 +202,13 @@ def test_models_validator_validate_typing_cleanup(event): @pytest.mark.parametrize( "event, models, expected", - [({"foo": 1}, [Server, create_model("A", foo=1)], create_model("A", foo=1))], + [ + ( + {"foo": 1}, + [Server, create_model("A", foo=(int, 1))], + create_model("A", foo=(int, 1)), + ) + ], ) def test_models_validator_get_first_valid_model_with_match(event, models, expected): """Test that the `get_first_valid_model` method returns the expected model.""" @@ -214,7 +218,10 @@ def dummy_get_models(event: dict): validator = Validator(ModelSelector(module="os")) validator.model_selector.get_models = dummy_get_models - assert validator.get_first_valid_model(event) == expected(**event) + assert ( + validator.get_first_valid_model(event).model_dump() + == expected(**event).model_dump() + ) @pytest.mark.parametrize( diff --git a/tests/models/xapi/base/test_agents.py b/tests/models/xapi/base/test_agents.py index 0a7b48dae..b4223edfa 100644 --- a/tests/models/xapi/base/test_agents.py +++ b/tests/models/xapi/base/test_agents.py @@ -8,16 +8,14 @@ from ralph.models.xapi.base.agents import BaseXapiAgentWithMboxSha1Sum -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_xapi_instance -@custom_given(BaseXapiAgentWithMboxSha1Sum) -def test_models_xapi_base_agent_with_mbox_sha1_sum_ifi_with_valid_field( - field, -): +def test_models_xapi_base_agent_with_mbox_sha1_sum_ifi_with_valid_field(): """Test a valid BaseXapiAgentWithMboxSha1Sum has the expected `mbox_sha1sum` regex. """ + field = mock_xapi_instance(BaseXapiAgentWithMboxSha1Sum) assert re.match(r"^[0-9a-f]{40}$", field.mbox_sha1sum) @@ -30,18 +28,17 @@ def test_models_xapi_base_agent_with_mbox_sha1_sum_ifi_with_valid_field( "1baccdd9abcdfd4ae9b24dedfa939c7deffa3db3a7", ], ) -@custom_given(BaseXapiAgentWithMboxSha1Sum) -def test_models_xapi_base_agent_with_mbox_sha1_sum_ifi_with_invalid_field( - mbox_sha1sum, field -): +def test_models_xapi_base_agent_with_mbox_sha1_sum_ifi_with_invalid_field(mbox_sha1sum): """Test an invalid `mbox_sha1sum` property in BaseXapiAgentWithMboxSha1Sum raises a `ValidationError`. """ - invalid_field = json.loads(field.json()) + field = mock_xapi_instance(BaseXapiAgentWithMboxSha1Sum) + + invalid_field = json.loads(field.model_dump_json()) invalid_field["mbox_sha1sum"] = mbox_sha1sum with pytest.raises( - ValidationError, match="mbox_sha1sum\n string does not match regex" + ValidationError, match="mbox_sha1sum\n String should match pattern" ): BaseXapiAgentWithMboxSha1Sum(**invalid_field) diff --git a/tests/models/xapi/base/test_common.py b/tests/models/xapi/base/test_common.py index 524bffc0e..40e5db1d7 100644 --- a/tests/models/xapi/base/test_common.py +++ b/tests/models/xapi/base/test_common.py @@ -28,16 +28,16 @@ class DummyIRIModel(BaseModel): @pytest.mark.parametrize( - "values,error", + "values", [ - ({"iri": None}, "none is not an allowed value"), - ({"iri": {}}, "expected string"), - ({"iri": ""}, "not a valid 'IRI'"), - ({"iri": "localhost/foo/bar"}, "not a valid 'IRI'"), - ({"iri": "http://foo/"}, "not a valid 'IRI'"), + {"iri": None}, + {"iri": {}}, + {"iri": ""}, + {"iri": "localhost/foo/bar"}, + {"iri": "http://foo/"}, ], ) -def test_models_xapi_base_common_field_iri_with_invalid_data(values, error): +def test_models_xapi_base_common_field_iri_with_invalid_data(values): """Test that an invalid verb field raises a `ValidationError`.""" class DummyIRIModel(BaseModel): @@ -45,7 +45,7 @@ class DummyIRIModel(BaseModel): iri: IRI - with pytest.raises(ValidationError, match=error): + with pytest.raises(ValidationError, match="not a valid 'IRI'"): DummyIRIModel(**values) @@ -72,15 +72,15 @@ class DummyLanguageTagModel(BaseModel): @pytest.mark.parametrize( - "values,error", + "values", [ - ({"tag": None}, "none is not an allowed value"), - ({"tag": {}}, "unhashable type: 'dict'"), - ({"tag": ""}, "Invalid RFC 5646 Language tag"), - ({"tag": "en-US-en"}, "Invalid RFC 5646 Language tag"), + {"tag": None}, + {"tag": {}}, + {"tag": ""}, + {"tag": "en-US-en"}, ], ) -def test_models_xapi_base_common_field_language_tag_with_invalid_data(values, error): +def test_models_xapi_base_common_field_language_tag_with_invalid_data(values): """Test that an invalid verb field raises a `ValidationError`.""" class DummyLanguageTagModel(BaseModel): @@ -88,7 +88,7 @@ class DummyLanguageTagModel(BaseModel): tag: LanguageTag - with pytest.raises(ValidationError, match=error): + with pytest.raises(TypeError, match="Invalid RFC 5646 Language tag"): DummyLanguageTagModel(**values) @@ -108,15 +108,25 @@ class DummyLanguageMapModel(BaseModel): @pytest.mark.parametrize( - "values,error", + "values,exception,error", [ - ({"map": None}, "none is not an allowed value"), - ({"map": "en-US-en"}, "value is not a valid dict"), - ({"map": {"en-US-en": 1}}, "Invalid RFC 5646 Language tag"), - ({"map": {"en-US": []}}, "en-US\n str type expected"), + ({"map": None}, ValidationError, "map\n Input should be a valid dictionary"), + ( + {"map": "en-US-en"}, + ValidationError, + "map\n Input should be a valid dictionary", + ), + ({"map": {"en-US-en": 1}}, TypeError, "Invalid RFC 5646 Language tag"), + ( + {"map": {"en-US": []}}, + ValidationError, + "en-US\n Input should be a valid string", + ), ], ) -def test_models_xapi_base_common_field_language_map_with_invalid_data(values, error): +def test_models_xapi_base_common_field_language_map_with_invalid_data( + values, exception, error +): """Test that an invalid verb field raises a `ValidationError`.""" class DummyLanguageTagModel(BaseModel): @@ -124,5 +134,5 @@ class DummyLanguageTagModel(BaseModel): map: LanguageMap - with pytest.raises(ValidationError, match=error): + with pytest.raises(exception, match=error): DummyLanguageTagModel(**values) diff --git a/tests/models/xapi/base/test_groups.py b/tests/models/xapi/base/test_groups.py index c3ad162c1..9bb41bbff 100644 --- a/tests/models/xapi/base/test_groups.py +++ b/tests/models/xapi/base/test_groups.py @@ -2,15 +2,13 @@ from ralph.models.xapi.base.groups import BaseXapiGroupCommonProperties -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_xapi_instance -@custom_given(BaseXapiGroupCommonProperties) -def test_models_xapi_base_groups_group_common_properties_with_valid_field( - field, -): +def test_models_xapi_base_groups_group_common_properties_with_valid_field(): """Test a valid BaseXapiGroupCommonProperties has the expected `objectType` value. """ + field = mock_xapi_instance(BaseXapiGroupCommonProperties) assert field.objectType == "Group" diff --git a/tests/models/xapi/base/test_objects.py b/tests/models/xapi/base/test_objects.py index 8129c48a5..1cd225b3b 100644 --- a/tests/models/xapi/base/test_objects.py +++ b/tests/models/xapi/base/test_objects.py @@ -2,11 +2,11 @@ from ralph.models.xapi.base.objects import BaseXapiSubStatement -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_xapi_instance -@custom_given(BaseXapiSubStatement) -def test_models_xapi_object_base_sub_statement_type_with_valid_field(field): +def test_models_xapi_object_base_sub_statement_type_with_valid_field(): """Test a valid BaseXapiSubStatement has the expected `objectType` value.""" + field = mock_xapi_instance(BaseXapiSubStatement) assert field.objectType == "SubStatement" diff --git a/tests/models/xapi/base/test_results.py b/tests/models/xapi/base/test_results.py index 61164f411..71a7c4f83 100644 --- a/tests/models/xapi/base/test_results.py +++ b/tests/models/xapi/base/test_results.py @@ -7,7 +7,7 @@ from ralph.models.xapi.base.results import BaseXapiResultScore -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_xapi_instance @pytest.mark.parametrize( @@ -18,15 +18,15 @@ (12, 5, 10, "raw cannot be greater than max"), ], ) -@custom_given(BaseXapiResultScore) def test_models_xapi_base_result_score_with_invalid_raw_min_max_relation( - raw_value, min_value, max_value, error_msg, field + raw_value, min_value, max_value, error_msg ): """Test invalids `raw`,`min`,`max` relation in BaseXapiResultScore raises ValidationError. """ + field = mock_xapi_instance(BaseXapiResultScore) - invalid_field = json.loads(field.json()) + invalid_field = json.loads(field.model_dump_json()) invalid_field["raw"] = raw_value invalid_field["min"] = min_value invalid_field["max"] = max_value diff --git a/tests/models/xapi/base/test_statements.py b/tests/models/xapi/base/test_statements.py index a88fca426..156188e01 100644 --- a/tests/models/xapi/base/test_statements.py +++ b/tests/models/xapi/base/test_statements.py @@ -3,8 +3,6 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from pydantic import ValidationError from ralph.models.selector import ModelSelector @@ -25,16 +23,15 @@ ) from ralph.utils import set_dict_value_from_path -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import ModelFactory, mock_xapi_instance @pytest.mark.parametrize( "path", - ["id", "stored", "verb__display", "context__contextActivities__parent"], + ["id", "stored", "verb__display", "result__score__raw"], ) @pytest.mark.parametrize("value", [None, "", {}]) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_with_invalid_null_values(path, value, statement): +def test_models_xapi_base_statement_with_invalid_null_values(path, value): """Test that the statement does not accept any null values. XAPI-00001 @@ -42,8 +39,11 @@ def test_models_xapi_base_statement_with_invalid_null_values(path, value, statem value is set to "null", an empty object, or has no value, except in an "extensions" property. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) + with pytest.raises(ValidationError, match="invalid empty value"): BaseXapiStatement(**statement) @@ -56,9 +56,8 @@ def test_models_xapi_base_statement_with_invalid_null_values(path, value, statem "context__extensions__https://w3id.org/xapi/video", ], ) -@pytest.mark.parametrize("value", [None, "", {}]) -@custom_given(custom_builds(BaseXapiStatement, object=custom_builds(BaseXapiActivity))) -def test_models_xapi_base_statement_with_valid_null_values(path, value, statement): +@pytest.mark.parametrize("value", [None, {}]) +def test_models_xapi_base_statement_with_valid_null_values(path, value): """Test that the statement does accept valid null values in extensions fields. XAPI-00001 @@ -66,8 +65,15 @@ def test_models_xapi_base_statement_with_valid_null_values(path, value, statemen value is set to "null", an empty object, or has no value, except in an "extensions" property. """ - statement = statement.dict(exclude_none=True) + + statement = mock_xapi_instance( + BaseXapiStatement, object=mock_xapi_instance(BaseXapiActivity) + ) + + statement = statement.model_dump(exclude_none=True) + set_dict_value_from_path(statement, path.split("__"), value) + try: BaseXapiStatement(**statement) except ValidationError as err: @@ -75,22 +81,22 @@ def test_models_xapi_base_statement_with_valid_null_values(path, value, statemen @pytest.mark.parametrize("path", ["object__definition__correctResponsesPattern"]) -@custom_given( - custom_builds( - BaseXapiStatement, - object=custom_builds( - BaseXapiActivity, - definition=custom_builds(BaseXapiActivityInteractionDefinition), - ), - ) -) -def test_models_xapi_base_statement_with_valid_empty_array(path, statement): +def test_models_xapi_base_statement_with_valid_empty_array(path): """Test that the statement does accept a valid empty array. Where the Correct Responses Pattern contains an empty array, the meaning of this is that there is no correct answer. """ - statement = statement.dict(exclude_none=True) + + statement = mock_xapi_instance( + BaseXapiStatement, + object=mock_xapi_instance( + BaseXapiActivity, + definition=mock_xapi_instance(BaseXapiActivityInteractionDefinition), + ), + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), []) try: BaseXapiStatement(**statement) @@ -102,8 +108,7 @@ def test_models_xapi_base_statement_with_valid_empty_array(path, statement): "field", ["actor", "verb", "object"], ) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_must_use_actor_verb_and_object(field, statement): +def test_models_xapi_base_statement_must_use_actor_verb_and_object(field): """Test that the statement raises an exception if required fields are missing. XAPI-00003 @@ -116,54 +121,86 @@ def test_models_xapi_base_statement_must_use_actor_verb_and_object(field, statem An LRS rejects with error code 400 Bad Request a Statement which does not contain an "object" property. """ - statement = statement.dict(exclude_none=True) + + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) + del statement["context"] # Necessary as context leads to another validation error del statement[field] - with pytest.raises(ValidationError, match="field required"): + with pytest.raises(ValidationError, match="Field required"): BaseXapiStatement(**statement) @pytest.mark.parametrize( - "path,value", + "path,value,err", [ - ("actor__name", 1), # Should be a string - ("actor__account__name", 1), # Should be a string - ("actor__account__homePage", 1), # Should be an IRI - ("actor__account", ["foo", "bar"]), # Should be a dictionary - ("verb__display", ["foo"]), # Should be a dictionary - ("verb__display", {"en": 1}), # Should have string values - ("object__id", ["foo"]), # Should be an IRI + ("actor__name", 1, "name\n Input should be a valid string"), + ("actor__account__name", 1, "account.name\n Input should be a valid string"), + ( + "actor__account__homePage", + 1, + "homePage\n Value error, '1' is not a valid 'IRI'", + ), + ( + "actor__account", + ["foo", "bar"], + ( + "account\n Input should be a valid dictionary or instance of " + "BaseXapiAccount" + ), + ), + ("verb__display", ["foo"], "display\n Input should be a valid dictionary"), + ("verb__display", {"en": 1}, "display.en\n Input should be a valid string"), + ("object__id", ["foo"], "is not a valid 'IRI'"), ], ) -@custom_given( - custom_builds(BaseXapiStatement, actor=custom_builds(BaseXapiAgentWithAccount)) -) -def test_models_xapi_base_statement_with_invalid_data_types(path, value, statement): +def test_models_xapi_base_statement_with_invalid_data_types(path, value, err): """Test that the statement does not accept values with wrong types. XAPI-00006 An LRS rejects with error code 400 Bad Request a Statement which uses the wrong data type. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(BaseXapiAgentWithAccount) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) - err = "(type expected|not a valid dict|expected string )" + + # err = "(type expected|not a valid dict|expected string )" + with pytest.raises(ValidationError, match=err): BaseXapiStatement(**statement) @pytest.mark.parametrize( - "path,value", + "path,value,exception,err", [ - ("id", "0545fe73-1bbd-4f84-9c9a"), # Should be a valid UUID - ("actor", {"mbox": "example@mail.com"}), # Should be a Mailto IRI - ("verb__display", {"bad language tag": "foo"}), # Should be a valid LanguageTag - ("object__id", ["This is not an IRI"]), # Should be an IRI + ( + "id", + "0545fe73-1bbd-4f84-9c9a", + ValidationError, + "id\n Input should be a valid UUID", + ), + ( + "actor", + {"mbox": "example@mail.com"}, + TypeError, + "Invalid `mailto:email` value", + ), + ( + "verb__display", + {"bad language tag": "foo"}, + TypeError, + "Invalid RFC 5646 Language tag", + ), + ("object__id", ["This is not an IRI"], ValidationError, "is not a valid 'IRI'"), ], ) -@custom_given( - custom_builds(BaseXapiStatement, actor=custom_builds(BaseXapiAgentWithAccount)) -) -def test_models_xapi_base_statement_with_invalid_data_format(path, value, statement): +def test_models_xapi_base_statement_with_invalid_data_format( + path, value, exception, err +): """Test that the statement does not accept values having a wrong format. XAPI-00007 @@ -172,57 +209,65 @@ def test_models_xapi_base_statement_with_invalid_data_format(path, value, statem particular format (such as mailto IRI, UUID, or IRI) is required. (Empty strings are covered by XAPI-00001) """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(BaseXapiAgentWithAccount) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) - err = "(Invalid `mailto:email`|Invalid RFC 5646 Language tag|not a valid uuid)" - with pytest.raises(ValidationError, match=err): + with pytest.raises(exception, match=err): BaseXapiStatement(**statement) @pytest.mark.parametrize("path,value", [("actor__objecttype", "Agent")]) -@custom_given( - custom_builds(BaseXapiStatement, actor=custom_builds(BaseXapiAgentWithAccount)) -) -def test_models_xapi_base_statement_with_invalid_letter_cases(path, value, statement): +def test_models_xapi_base_statement_with_invalid_letter_cases(path, value): """Test that the statement does not accept keys having invalid letter cases. XAPI-00008 An LRS rejects with error code 400 Bad Request a Statement where the case of a key does not match the case specified in this specification. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(BaseXapiAgentWithAccount) + ) + + statement = statement.model_dump(exclude_none=True) if statement["actor"].get("objectType", None): del statement["actor"]["objectType"] set_dict_value_from_path(statement, path.split("__"), value) - err = "(unexpected value|extra fields not permitted)" + err = "(unexpected value|Extra inputs are not permitted)" with pytest.raises(ValidationError, match=err): BaseXapiStatement(**statement) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_should_not_accept_additional_properties(statement): +def test_models_xapi_base_statement_should_not_accept_additional_properties(): """Test that the statement does not accept additional properties. XAPI-00010 An LRS rejects with error code 400 Bad Request a Statement where a key or value is not allowed by this specification. """ - invalid_statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + invalid_statement = statement.model_dump(exclude_none=True) invalid_statement["NEW_INVALID_FIELD"] = "some value" - with pytest.raises(ValidationError, match="extra fields not permitted"): + with pytest.raises( + ValidationError, match="NEW_INVALID_FIELD\n Extra inputs are not permitted" + ): BaseXapiStatement(**invalid_statement) @pytest.mark.parametrize("path,value", [("object__id", "w3id.org/xapi/video")]) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_with_iri_without_scheme(path, value, statement): +def test_models_xapi_base_statement_with_iri_without_scheme(path, value): """Test that the statement does not accept IRIs without a scheme. XAPI-00011 An LRS rejects with error code 400 Bad Request a Statement containing IRL or IRI values without a scheme. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) with pytest.raises(ValidationError, match="is not a valid 'IRI'"): BaseXapiStatement(**statement) @@ -236,51 +281,56 @@ def test_models_xapi_base_statement_with_iri_without_scheme(path, value, stateme "context__extensions__w3id.org/xapi/video", ], ) -@custom_given(custom_builds(BaseXapiStatement, object=custom_builds(BaseXapiActivity))) -def test_models_xapi_base_statement_with_invalid_extensions(path, statement): +def test_models_xapi_base_statement_with_invalid_extensions(path): """Test that the statement does not accept extensions keys with invalid IRIs. XAPI-00118 An Extension "key" is an IRI. The LRS rejects with 400 a statement which has an extension key which is not a valid IRI, if an extension object is present. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, object=mock_xapi_instance(BaseXapiActivity) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), "") with pytest.raises(ValidationError, match="is not a valid 'IRI'"): BaseXapiStatement(**statement) @pytest.mark.parametrize("path,value", [("actor__mbox", "mailto:example@mail.com")]) -@custom_given( - custom_builds(BaseXapiStatement, actor=custom_builds(BaseXapiAgentWithAccount)) -) -def test_models_xapi_base_statement_with_two_agent_types(path, value, statement): +def test_models_xapi_base_statement_with_two_agent_types(path, value): """Test that the statement does not accept multiple agent types. An Agent MUST NOT include more than one Inverse Functional Identifier. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(BaseXapiAgentWithAccount) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) - with pytest.raises(ValidationError, match="extra fields not permitted"): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): BaseXapiStatement(**statement) -@custom_given( - custom_builds(BaseXapiStatement, actor=custom_builds(BaseXapiAnonymousGroup)) -) -def test_models_xapi_base_statement_missing_member_property(statement): +def test_models_xapi_base_statement_missing_member_property(): """Test that the statement does not accept group agents with missing members. An Anonymous Group MUST include a "member" property listing constituent Agents. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(BaseXapiAnonymousGroup) + ) + + statement = statement.model_dump(exclude_none=True) del statement["actor"]["member"] - with pytest.raises(ValidationError, match="member\n field required"): + with pytest.raises(ValidationError, match="member\n Field required"): BaseXapiStatement(**statement) @pytest.mark.parametrize( - "value", + "klass", [ BaseXapiAnonymousGroup, BaseXapiIdentifiedGroupWithMbox, @@ -289,57 +339,57 @@ def test_models_xapi_base_statement_missing_member_property(statement): BaseXapiIdentifiedGroupWithAccount, ], ) -@custom_given( - st.one_of( - custom_builds(BaseXapiStatement, actor=custom_builds(BaseXapiAnonymousGroup)), - custom_builds( - BaseXapiStatement, - actor=custom_builds(BaseXapiIdentifiedGroupWithMbox), - ), - custom_builds( - BaseXapiStatement, - actor=custom_builds(BaseXapiIdentifiedGroupWithMboxSha1Sum), - ), - custom_builds( - BaseXapiStatement, - actor=custom_builds(BaseXapiIdentifiedGroupWithOpenId), - ), - custom_builds( - BaseXapiStatement, - actor=custom_builds(BaseXapiIdentifiedGroupWithAccount), - ), - ), - st.data(), -) -def test_models_xapi_base_statement_with_invalid_group_objects(value, statement, data): +def test_models_xapi_base_statement_with_invalid_group_objects(klass): """Test that the statement does not accept group agents with group members. An Anonymous Group MUST NOT contain Group Objects in the "member" identifiers. An Identified Group MUST NOT contain Group Objects in the "member" property. """ + + actor_class = ModelFactory.__random__.choice( + [ + BaseXapiAnonymousGroup, + BaseXapiIdentifiedGroupWithMbox, + BaseXapiIdentifiedGroupWithMboxSha1Sum, + BaseXapiIdentifiedGroupWithOpenId, + BaseXapiIdentifiedGroupWithAccount, + ] + ) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(actor_class) + ) + kwargs = {"exclude_none": True} - statement = statement.dict(**kwargs) - statement["actor"]["member"] = [data.draw(custom_builds(value)).dict(**kwargs)] - err = "actor -> member -> 0 -> objectType\n unexpected value; permitted: 'Agent'" - with pytest.raises(ValidationError, match=err): - BaseXapiStatement(**statement) + statement = statement.model_dump(**kwargs) + statement["actor"]["member"] = [mock_xapi_instance(klass).model_dump(**kwargs)] + + for class_ in [ + "BaseXapiAgentWithMbox", + "BaseXapiAgentWithMboxSha1Sum", + "BaseXapiAgentWithOpenId", + "BaseXapiAgentWithAccount", + ]: + err = ( + f"actor.BaseXapiAnonymousGroup.member.0.{class_}.objectType\n " + "Input should be 'Agent'" + ) + with pytest.raises(ValidationError, match=err): + BaseXapiStatement(**statement) @pytest.mark.parametrize("path,value", [("actor__mbox", "mailto:example@mail.com")]) -@custom_given( - custom_builds( - BaseXapiStatement, - actor=custom_builds(BaseXapiIdentifiedGroupWithAccount), - ) -) -def test_models_xapi_base_statement_with_two_group_identifiers(path, value, statement): +def test_models_xapi_base_statement_with_two_group_identifiers(path, value): """Test that the statement does not accept multiple group identifiers. An Identified Group MUST include exactly one Inverse Functional Identifier. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, actor=mock_xapi_instance(BaseXapiIdentifiedGroupWithAccount) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) - with pytest.raises(ValidationError, match="extra fields not permitted"): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): BaseXapiStatement(**statement) @@ -352,48 +402,53 @@ def test_models_xapi_base_statement_with_two_group_identifiers(path, value, stat ("object__authority", {"mbox": "mailto:example@mail.com"}), ], ) -@custom_given( - custom_builds(BaseXapiStatement, object=custom_builds(BaseXapiSubStatement)) -) -def test_models_xapi_base_statement_with_sub_statement_ref(path, value, statement): +def test_models_xapi_base_statement_with_sub_statement_ref(path, value): """Test that the sub-statement does not accept invalid properties. A SubStatement MUST NOT have the "id", "stored", "version" or "authority" properties. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, object=mock_xapi_instance(BaseXapiSubStatement) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) - with pytest.raises(ValidationError, match="extra fields not permitted"): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): BaseXapiStatement(**statement) @pytest.mark.parametrize( - "value", + "value,err", [ - [{"id": "invalid whitespace"}], - [{"id": "valid"}, {"id": "invalid whitespace"}], - [{"id": "invalid_duplicate"}, {"id": "invalid_duplicate"}], - ], -) -@custom_given( - custom_builds( - BaseXapiStatement, - object=custom_builds( - BaseXapiActivity, - definition=custom_builds(BaseXapiActivityInteractionDefinition), + ([{"id": "invalid whitespace"}], "String should match pattern"), + ( + [{"id": "valid"}, {"id": "invalid whitespace"}], + "String should match pattern", ), - ) + ( + [{"id": "invalid_duplicate"}, {"id": "invalid_duplicate"}], + "Duplicate InteractionComponents are not valid", + ), + ], ) -def test_models_xapi_base_statement_with_invalid_interaction_object(value, statement): +def test_models_xapi_base_statement_with_invalid_interaction_object(value, err): """Test that the statement does not accept invalid interaction fields. An interaction component's id value SHOULD NOT have whitespace. Within an array of interaction components, all id values MUST be distinct. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance( + BaseXapiStatement, + object=mock_xapi_instance( + BaseXapiActivity, + definition=mock_xapi_instance(BaseXapiActivityInteractionDefinition), + ), + ) + + statement = statement.model_dump(exclude_none=True) path = "object.definition.scale".split(".") set_dict_value_from_path(statement, path, value) - err = "(Duplicate InteractionComponents are not valid|string does not match regex)" with pytest.raises(ValidationError, match=err): BaseXapiStatement(**statement) @@ -405,19 +460,24 @@ def test_models_xapi_base_statement_with_invalid_interaction_object(value, state ("context__platform", "FUN MOOC"), ], ) -@custom_given( - st.one_of( - custom_builds(BaseXapiStatement, object=custom_builds(BaseXapiSubStatement)), - custom_builds(BaseXapiStatement, object=custom_builds(BaseXapiStatementRef)), - ), -) -def test_models_xapi_base_statement_with_invalid_context_value(path, value, statement): +def test_models_xapi_base_statement_with_invalid_context_value(path, value): """Test that the statement does not accept an invalid revision/platform value. The "revision" property MUST only be used if the Statement's Object is an Activity. The "platform" property MUST only be used if the Statement's Object is an Activity. """ - statement = statement.dict(exclude_none=True) + + object_class = ModelFactory.__random__.choice( + [ + BaseXapiSubStatement, + BaseXapiStatementRef, + ] + ) + statement = mock_xapi_instance( + BaseXapiStatement, object=mock_xapi_instance(object_class) + ) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("__"), value) err = "properties can only be used if the Statement's Object is an Activity" with pytest.raises(ValidationError, match=err): @@ -425,16 +485,17 @@ def test_models_xapi_base_statement_with_invalid_context_value(path, value, stat @pytest.mark.parametrize("path", ["context.contextActivities.not_parent"]) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_with_invalid_context_activities(path, statement): +def test_models_xapi_base_statement_with_invalid_context_activities(path): """Test that the statement does not accept invalid context activity properties. Every key in the contextActivities Object MUST be one of parent, grouping, category, or other. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, path.split("."), {"id": "http://w3id.org/xapi"}) - with pytest.raises(ValidationError, match="extra fields not permitted"): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): BaseXapiStatement(**statement) @@ -446,14 +507,15 @@ def test_models_xapi_base_statement_with_invalid_context_activities(path, statem [{"id": "http://w3id.org/xapi"}, {"id": "http://w3id.org/xapi/video"}], ], ) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_with_valid_context_activities(value, statement): +def test_models_xapi_base_statement_with_valid_context_activities(value): """Test that the statement does accept valid context activities fields. Every value in the contextActivities Object MUST be either a single Activity Object or an array of Activity Objects. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) path = ["context", "contextActivities"] for activity in ["parent", "grouping", "category", "other"]: set_dict_value_from_path(statement, path + [activity], value) @@ -464,46 +526,48 @@ def test_models_xapi_base_statement_with_valid_context_activities(value, stateme @pytest.mark.parametrize("value", ["0.0.0", "1.1.0", "1", "2", "1.10.1", "1.0.1.1"]) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_with_invalid_version(value, statement): +def test_models_xapi_base_statement_with_invalid_version(value): """Test that the statement does not accept an invalid version field. An LRS MUST reject all Statements with a version specified that does not start with 1.0. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, ["version"], value) - with pytest.raises(ValidationError, match="version\n string does not match regex"): + with pytest.raises(ValidationError, match="version\n String should match pattern"): BaseXapiStatement(**statement) -@custom_given(BaseXapiStatement) -def test_models_xapi_base_statement_with_valid_version(statement): +def test_models_xapi_base_statement_with_valid_version(): """Test that the statement does accept a valid version field. Statements returned by an LRS MUST retain the version they are accepted with. If they lack a version, the version MUST be set to 1.0.0. """ - statement = statement.dict(exclude_none=True) + statement = mock_xapi_instance(BaseXapiStatement) + + statement = statement.model_dump(exclude_none=True) set_dict_value_from_path(statement, ["version"], "1.0.3") - assert "1.0.3" == BaseXapiStatement(**statement).dict()["version"] + assert "1.0.3" == BaseXapiStatement(**statement).model_dump()["version"] del statement["version"] - assert "1.0.0" == BaseXapiStatement(**statement).dict()["version"] + assert "1.0.0" == BaseXapiStatement(**statement).model_dump()["version"] -@settings(deadline=None) @pytest.mark.parametrize( "model", list(ModelSelector("ralph.models.xapi").model_rules), ) -@custom_given(st.data()) def test_models_xapi_base_statement_should_consider_valid_all_defined_xapi_models( - model, data + model, ): """Test that all defined xAPI models in the ModelSelector make valid statements.""" + # All specific xAPI models should inherit BaseXapiStatement assert issubclass(model, BaseXapiStatement) - statement = data.draw(custom_builds(model)).json(exclude_none=True, by_alias=True) + statement = mock_xapi_instance(model) + statement = statement.json(exclude_none=True, by_alias=True) try: BaseXapiStatement(**json.loads(statement)) except ValidationError as err: diff --git a/tests/models/xapi/base/test_unnested_objects.py b/tests/models/xapi/base/test_unnested_objects.py index a5bc5a24f..e41882b20 100644 --- a/tests/models/xapi/base/test_unnested_objects.py +++ b/tests/models/xapi/base/test_unnested_objects.py @@ -12,22 +12,18 @@ BaseXapiStatementRef, ) -from tests.fixtures.hypothesis_strategies import custom_given +from tests.factories import mock_xapi_instance -@custom_given(BaseXapiStatementRef) -def test_models_xapi_base_object_statement_ref_type_with_valid_field(field): +def test_models_xapi_base_object_statement_ref_type_with_valid_field(): """Test a valid BaseXapiStatementRef has the expected `objectType` value.""" - + field = mock_xapi_instance(BaseXapiStatementRef) assert field.objectType == "StatementRef" -@custom_given(BaseXapiInteractionComponent) -def test_models_xapi_base_object_interaction_component_with_valid_field( - field, -): +def test_models_xapi_base_object_interaction_component_with_valid_field(): """Test a valid BaseXapiInteractionComponent has the expected `id` regex.""" - + field = mock_xapi_instance(BaseXapiInteractionComponent) assert re.match(r"^[^\s]+$", field.id) @@ -35,28 +31,24 @@ def test_models_xapi_base_object_interaction_component_with_valid_field( "id_value", [" test_id", "\ntest"], ) -@custom_given(BaseXapiInteractionComponent) -def test_models_xapi_base_object_interaction_component_with_invalid_field( - id_value, field -): +def test_models_xapi_base_object_interaction_component_with_invalid_field(id_value): """Test an invalid `id` property in BaseXapiInteractionComponent raises a `ValidationError`. """ + field = mock_xapi_instance(BaseXapiInteractionComponent) invalid_property = json.loads(field.json()) invalid_property["id"] = id_value - with pytest.raises(ValidationError, match="id\n string does not match regex"): + with pytest.raises(ValidationError, match="id\n String should match pattern"): BaseXapiInteractionComponent(**invalid_property) -@custom_given(BaseXapiActivityInteractionDefinition) -def test_models_xapi_base_object_activity_type_interaction_definition_with_valid_field( - field, -): +def test_models_xapi_base_object_activity_type_interaction_definition_with_valid_field(): # noqa: E501 """Test a valid BaseXapiActivityInteractionDefinition has the expected `objectType` value. """ + field = mock_xapi_instance(BaseXapiActivityInteractionDefinition) assert field.interactionType in ( "true-false", diff --git a/tests/models/xapi/concepts/test_activity_types.py b/tests/models/xapi/concepts/test_activity_types.py index e0d535084..604ce02b7 100644 --- a/tests/models/xapi/concepts/test_activity_types.py +++ b/tests/models/xapi/concepts/test_activity_types.py @@ -2,8 +2,6 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from ralph.models.xapi.concepts.activity_types.acrossx_profile import ( MessageActivity, @@ -26,10 +24,9 @@ VirtualClassroomActivity, ) -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_xapi_instance -@settings(deadline=None) @pytest.mark.parametrize( "class_, definition_type", [ @@ -50,12 +47,9 @@ (DocumentActivity, "http://id.tincanapi.com/activitytype/document"), ], ) -@custom_given(st.data()) -def test_models_xapi_concept_activity_types_with_valid_field( - class_, definition_type, data -): +def test_models_xapi_concept_activity_types_with_valid_field(class_, definition_type): """Test that a valid xAPI activity has the expected the `definition`.`type` value. """ - field = json.loads(data.draw(custom_builds(class_)).json()) + field = json.loads(mock_xapi_instance(class_).model_dump_json()) assert field["definition"]["type"] == definition_type diff --git a/tests/models/xapi/concepts/test_verbs.py b/tests/models/xapi/concepts/test_verbs.py index a69a4a097..885dfff4a 100644 --- a/tests/models/xapi/concepts/test_verbs.py +++ b/tests/models/xapi/concepts/test_verbs.py @@ -2,8 +2,6 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from ralph.models.xapi.concepts.verbs.acrossx_profile import PostedVerb from ralph.models.xapi.concepts.verbs.activity_streams_vocabulary import ( @@ -42,10 +40,9 @@ UnsharedScreenVerb, ) -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_xapi_instance -@settings(deadline=None) @pytest.mark.parametrize( "class_, verb_id", [ @@ -89,8 +86,7 @@ ), ], ) -@custom_given(st.data()) -def test_models_xapi_concept_verbs_with_valid_field(class_, verb_id, data): +def test_models_xapi_concept_verbs_with_valid_field(class_, verb_id): """Test that a valid xAPI verb has the expected the `id` value.""" - field = json.loads(data.draw(custom_builds(class_)).json()) + field = json.loads(mock_xapi_instance(class_).model_dump_json()) assert field["id"] == verb_id diff --git a/tests/models/xapi/test_lms.py b/tests/models/xapi/test_lms.py index 82e9efe87..edc551b47 100644 --- a/tests/models/xapi/test_lms.py +++ b/tests/models/xapi/test_lms.py @@ -3,8 +3,6 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from pydantic import ValidationError from ralph.models.selector import ModelSelector @@ -24,10 +22,9 @@ LMSUploadedVideo, ) -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_xapi_instance -@settings(deadline=None) @pytest.mark.parametrize( "class_", [ @@ -45,21 +42,20 @@ LMSUploadedAudio, ], ) -@custom_given(st.data()) -def test_models_xapi_lms_selectors_with_valid_statements(class_, data): +def test_models_xapi_lms_selectors_with_valid_statements(class_): """Test given a valid LMS xAPI statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_xapi_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.xapi").get_first_model(statement) assert model is class_ -@custom_given(LMSRegisteredCourse) -def test_models_xapi_lms_registered_course_with_valid_statement(statement): +def test_models_xapi_lms_registered_course_with_valid_statement(): """Test that a valid registered to a course statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSRegisteredCourse) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/registered" assert ( @@ -67,11 +63,11 @@ def test_models_xapi_lms_registered_course_with_valid_statement(statement): ) -@custom_given(LMSUnregisteredCourse) -def test_models_xapi_lms_unregistered_course_with_valid_statement(statement): +def test_models_xapi_lms_unregistered_course_with_valid_statement(): """Test that a valid unregistered to a course statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSUnregisteredCourse) assert statement.verb.id == "http://id.tincanapi.com/verb/unregistered" assert ( @@ -79,11 +75,11 @@ def test_models_xapi_lms_unregistered_course_with_valid_statement(statement): ) -@custom_given(LMSAccessedPage) -def test_models_xapi_lms_accessed_page_with_valid_statement(statement): +def test_models_xapi_lms_accessed_page_with_valid_statement(): """Test that a valid accessed a page statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSAccessedPage) assert statement.verb.id == "https://w3id.org/xapi/netc/verbs/accessed" assert ( @@ -92,41 +88,41 @@ def test_models_xapi_lms_accessed_page_with_valid_statement(statement): ) -@custom_given(LMSAccessedFile) -def test_models_xapi_lms_accessed_file_with_valid_statement(statement): +def test_models_xapi_lms_accessed_file_with_valid_statement(): """Test that a valid accessed a file statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSAccessedFile) assert statement.verb.id == "https://w3id.org/xapi/netc/verbs/accessed" assert statement.object.definition.type == "http://activitystrea.ms/file" -@custom_given(LMSUploadedFile) -def test_models_xapi_lms_uploaded_file_with_valid_statement(statement): +def test_models_xapi_lms_uploaded_file_with_valid_statement(): """Test that a valid uploaded a file statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSUploadedFile) assert statement.verb.id == "https://w3id.org/xapi/netc/verbs/uploaded" assert statement.object.definition.type == "http://activitystrea.ms/file" -@custom_given(LMSDownloadedFile) -def test_models_xapi_lms_downloaded_file_with_valid_statement(statement): +def test_models_xapi_lms_downloaded_file_with_valid_statement(): """Test that a valid downloaded a file statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSDownloadedFile) assert statement.verb.id == "http://id.tincanapi.com/verb/downloaded" assert statement.object.definition.type == "http://activitystrea.ms/file" -@custom_given(LMSDownloadedVideo) -def test_models_xapi_lms_downloaded_video_with_valid_statement(statement): +def test_models_xapi_lms_downloaded_video_with_valid_statement(): """Test that a valid downloaded a video statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSDownloadedVideo) assert statement.verb.id == "http://id.tincanapi.com/verb/downloaded" assert ( @@ -135,11 +131,11 @@ def test_models_xapi_lms_downloaded_video_with_valid_statement(statement): ) -@custom_given(LMSUploadedVideo) -def test_models_xapi_lms_uploaded_video_with_valid_statement(statement): +def test_models_xapi_lms_uploaded_video_with_valid_statement(): """Test that a valid uploaded a video statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSUploadedVideo) assert statement.verb.id == "https://w3id.org/xapi/netc/verbs/uploaded" assert ( @@ -148,11 +144,11 @@ def test_models_xapi_lms_uploaded_video_with_valid_statement(statement): ) -@custom_given(LMSDownloadedDocument) -def test_models_xapi_lms_downloaded_document_with_valid_statement(statement): +def test_models_xapi_lms_downloaded_document_with_valid_statement(): """Test that a valid downloaded a document statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSDownloadedDocument) assert statement.verb.id == "http://id.tincanapi.com/verb/downloaded" assert ( @@ -161,11 +157,11 @@ def test_models_xapi_lms_downloaded_document_with_valid_statement(statement): ) -@custom_given(LMSUploadedDocument) -def test_models_xapi_lms_uploaded_document_with_valid_statement(statement): +def test_models_xapi_lms_uploaded_document_with_valid_statement(): """Test that a valid uploaded a document statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSUploadedDocument) assert statement.verb.id == "https://w3id.org/xapi/netc/verbs/uploaded" assert ( @@ -174,11 +170,11 @@ def test_models_xapi_lms_uploaded_document_with_valid_statement(statement): ) -@custom_given(LMSDownloadedAudio) -def test_models_xapi_lms_downloaded_audio_with_valid_statement(statement): +def test_models_xapi_lms_downloaded_audio_with_valid_statement(): """Test that a valid downloaded an audio statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSDownloadedAudio) assert statement.verb.id == "http://id.tincanapi.com/verb/downloaded" assert ( @@ -187,11 +183,11 @@ def test_models_xapi_lms_downloaded_audio_with_valid_statement(statement): ) -@custom_given(LMSUploadedAudio) -def test_models_xapi_lms_uploaded_audio_with_valid_statement(statement): +def test_models_xapi_lms_uploaded_audio_with_valid_statement(): """Test that a valid uploaded an audio statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(LMSUploadedAudio) assert statement.verb.id == "https://w3id.org/xapi/netc/verbs/uploaded" assert ( @@ -200,7 +196,6 @@ def test_models_xapi_lms_uploaded_audio_with_valid_statement(statement): ) -@settings(deadline=None) @pytest.mark.parametrize( "category", [ @@ -213,13 +208,11 @@ def test_models_xapi_lms_uploaded_audio_with_valid_statement(statement): [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/lms"}], ], ) -@custom_given(LMSContextContextActivities) -def test_models_xapi_lms_context_context_activities_with_valid_category( - category, context_activities -): +def test_models_xapi_lms_context_context_activities_with_valid_category(category): """Test that a valid `LMSContextContextActivities` should not raise a `ValidationError`. """ + context_activities = mock_xapi_instance(LMSContextContextActivities) activities = json.loads(context_activities.json(exclude_none=True, by_alias=True)) activities["category"] = category try: @@ -230,31 +223,46 @@ def test_models_xapi_lms_context_context_activities_with_valid_category( ) -@settings(deadline=None) @pytest.mark.parametrize( - "category", + "category,msg", [ - {"id": "https://w3id.org/xapi/not-lms"}, - { - "id": "https://w3id.org/xapi/lms", - "definition": {"type": "http://adlnet.gov/expapi/activities/not-profile"}, - }, - [{"id": "https://w3id.org/xapi/not-lms"}], - [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/not-lms"}], + ( + {"id": "https://w3id.org/xapi/not-lms"}, + r"Input should be 'https://w3id.org/xapi/lms'", + ), + ( + { + "id": "https://w3id.org/xapi/lms", + "definition": { + "type": "http://adlnet.gov/expapi/activities/not-profile" + }, + }, + r"Input should be 'http://adlnet.gov/expapi/activities/profile'", + ), + ( + [{"id": "https://w3id.org/xapi/not-lms"}], + ( + r"The `context.contextActivities.category` field should contain " + r"at least one valid `LMSProfileActivity`" + ), + ), + ( + [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/not-lms"}], + ( + r"The `context.contextActivities.category` field should contain " + r"at least one valid `LMSProfileActivity`" + ), + ), ], ) -@custom_given(LMSContextContextActivities) def test_models_xapi_lms_context_context_activities_with_invalid_category( - category, context_activities + category, msg ): """Test that an invalid `LMSContextContextActivities` should raise a `ValidationError`. """ + context_activities = mock_xapi_instance(LMSContextContextActivities) activities = json.loads(context_activities.json(exclude_none=True, by_alias=True)) activities["category"] = category - msg = ( - r"(The `context.contextActivities.category` field should contain at least one " - r"valid `LMSProfileActivity`) | (unexpected value)" - ) with pytest.raises(ValidationError, match=msg): LMSContextContextActivities(**activities) diff --git a/tests/models/xapi/test_navigation.py b/tests/models/xapi/test_navigation.py index 2a0293a81..c8fca2b25 100644 --- a/tests/models/xapi/test_navigation.py +++ b/tests/models/xapi/test_navigation.py @@ -3,40 +3,36 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from ralph.models.selector import ModelSelector from ralph.models.xapi.navigation.statements import PageTerminated, PageViewed -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_xapi_instance -@settings(deadline=None) @pytest.mark.parametrize("class_", [PageTerminated, PageViewed]) -@custom_given(st.data()) -def test_models_xapi_navigation_selectors_with_valid_statements(class_, data): +def test_models_xapi_navigation_selectors_with_valid_statements(class_): """Test given a valid navigation xAPI statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_xapi_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.xapi").get_first_model(statement) assert model is class_ -@custom_given(PageTerminated) -def test_models_xapi_navigation_page_terminated_with_valid_statement(statement): +def test_models_xapi_navigation_page_terminated_with_valid_statement(): """Test that a valid page_terminated statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(PageTerminated) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/terminated" assert statement.object.definition.type == "http://activitystrea.ms/schema/1.0/page" -@custom_given(PageViewed) -def test_models_xapi_page_viewed_with_valid_statement(statement): +def test_models_xapi_page_viewed_with_valid_statement(): """Test that a valid page_viewed statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(PageViewed) assert statement.verb.id == "http://id.tincanapi.com/verb/viewed" assert statement.object.definition.type == "http://activitystrea.ms/schema/1.0/page" diff --git a/tests/models/xapi/test_video.py b/tests/models/xapi/test_video.py index 52d1b974f..7042acfa7 100644 --- a/tests/models/xapi/test_video.py +++ b/tests/models/xapi/test_video.py @@ -3,8 +3,6 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from pydantic import ValidationError from ralph.models.selector import ModelSelector @@ -22,10 +20,9 @@ VideoVolumeChangeInteraction, ) -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_xapi_instance -@settings(deadline=None) @pytest.mark.parametrize( "class_", [ @@ -37,17 +34,15 @@ VideoTerminated, ], ) -@custom_given(st.data()) -def test_models_xapi_video_selectors_with_valid_statements(class_, data): +def test_models_xapi_video_selectors_with_valid_statements(class_): """Test given a valid video xAPI statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_xapi_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.xapi").get_first_model(statement) assert model is class_ -@settings(deadline=None) @pytest.mark.parametrize( "class_", [ @@ -56,14 +51,13 @@ def test_models_xapi_video_selectors_with_valid_statements(class_, data): VideoScreenChangeInteraction, ], ) -@custom_given(st.data()) -def test_models_xapi_video_interaction_validator_with_valid_statements(class_, data): +def test_models_xapi_video_interaction_validator_with_valid_statements(class_): """Test given a valid video interaction xAPI statement the `get_first_valid_model` validator method should return the expected model. """ statement = json.loads( - data.draw(custom_builds(class_)).json(exclude_none=True, by_alias=True) + mock_xapi_instance(class_).model_dump_json(exclude_none=True, by_alias=True) ) model = Validator(ModelSelector(module="ralph.models.xapi")).get_first_valid_model( @@ -73,11 +67,11 @@ def test_models_xapi_video_interaction_validator_with_valid_statements(class_, d assert isinstance(model, class_) -@custom_given(VideoInitialized) -def test_models_xapi_video_initialized_with_valid_statement(statement): +def test_models_xapi_video_initialized_with_valid_statement(): """Test that a valid video initialized statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoInitialized) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/initialized" assert ( @@ -86,11 +80,11 @@ def test_models_xapi_video_initialized_with_valid_statement(statement): ) -@custom_given(VideoPlayed) -def test_models_xapi_video_played_with_valid_statement(statement): +def test_models_xapi_video_played_with_valid_statement(): """Test that a valid video played statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoPlayed) assert statement.verb.id == "https://w3id.org/xapi/video/verbs/played" assert ( @@ -99,11 +93,11 @@ def test_models_xapi_video_played_with_valid_statement(statement): ) -@custom_given(VideoPaused) -def test_models_xapi_video_paused_with_valid_statement(statement): +def test_models_xapi_video_paused_with_valid_statement(): """Test that a video paused statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoPaused) assert statement.verb.id == "https://w3id.org/xapi/video/verbs/paused" assert ( @@ -112,11 +106,11 @@ def test_models_xapi_video_paused_with_valid_statement(statement): ) -@custom_given(VideoSeeked) -def test_models_xapi_video_seeked_with_valid_statement(statement): +def test_models_xapi_video_seeked_with_valid_statement(): """Test that a video seeked statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoSeeked) assert statement.verb.id == "https://w3id.org/xapi/video/verbs/seeked" assert ( @@ -125,11 +119,11 @@ def test_models_xapi_video_seeked_with_valid_statement(statement): ) -@custom_given(VideoCompleted) -def test_models_xapi_video_completed_with_valid_statement(statement): +def test_models_xapi_video_completed_with_valid_statement(): """Test that a video completed statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoCompleted) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/completed" assert ( @@ -138,11 +132,11 @@ def test_models_xapi_video_completed_with_valid_statement(statement): ) -@custom_given(VideoTerminated) -def test_models_xapi_video_terminated_with_valid_statement(statement): +def test_models_xapi_video_terminated_with_valid_statement(): """Test that a video terminated statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoTerminated) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/terminated" assert ( @@ -151,11 +145,11 @@ def test_models_xapi_video_terminated_with_valid_statement(statement): ) -@custom_given(VideoEnableClosedCaptioning) -def test_models_xapi_video_enable_closed_captioning_with_valid_statement(statement): +def test_models_xapi_video_enable_closed_captioning_with_valid_statement(): """Test that a video enable closed captioning statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoEnableClosedCaptioning) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/interacted" assert ( @@ -164,11 +158,11 @@ def test_models_xapi_video_enable_closed_captioning_with_valid_statement(stateme ) -@custom_given(VideoVolumeChangeInteraction) -def test_models_xapi_video_volume_change_interaction_with_valid_statement(statement): +def test_models_xapi_video_volume_change_interaction_with_valid_statement(): """Test that a video volume change interaction statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoVolumeChangeInteraction) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/interacted" assert ( @@ -177,11 +171,11 @@ def test_models_xapi_video_volume_change_interaction_with_valid_statement(statem ) -@custom_given(VideoScreenChangeInteraction) -def test_models_xapi_video_screen_change_interaction_with_valid_statement(statement): +def test_models_xapi_video_screen_change_interaction_with_valid_statement(): """Test that a video screen change interaction statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VideoScreenChangeInteraction) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/interacted" assert ( @@ -190,7 +184,6 @@ def test_models_xapi_video_screen_change_interaction_with_valid_statement(statem ) -@settings(deadline=None) @pytest.mark.parametrize( "category", [ @@ -203,13 +196,11 @@ def test_models_xapi_video_screen_change_interaction_with_valid_statement(statem [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/video"}], ], ) -@custom_given(VideoContextContextActivities) -def test_models_xapi_video_context_activities_with_valid_category( - category, context_activities -): +def test_models_xapi_video_context_activities_with_valid_category(category): """Test that a valid `VideoContextContextActivities` should not raise a `ValidationError`. """ + context_activities = mock_xapi_instance(VideoContextContextActivities) activities = json.loads(context_activities.json(exclude_none=True, by_alias=True)) activities["category"] = category try: @@ -220,31 +211,44 @@ def test_models_xapi_video_context_activities_with_valid_category( ) -@settings(deadline=None) @pytest.mark.parametrize( - "category", + "category,msg", [ - {"id": "https://w3id.org/xapi/not-video"}, - { - "id": "https://w3id.org/xapi/video", - "definition": {"type": "http://adlnet.gov/expapi/activities/not-profile"}, - }, - [{"id": "https://w3id.org/xapi/not-video"}], - [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/not-video"}], + ( + {"id": "https://w3id.org/xapi/not-video"}, + r"Input should be 'https://w3id.org/xapi/video'", + ), + ( + { + "id": "https://w3id.org/xapi/video", + "definition": { + "type": "http://adlnet.gov/expapi/activities/not-profile" + }, + }, + r"Input should be 'http://adlnet.gov/expapi/activities/profile'", + ), + ( + [{"id": "https://w3id.org/xapi/not-video"}], + ( + r"The `context.contextActivities.category` field should contain " + r"at least one valid `VideoProfileActivity`" + ), + ), + ( + [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/not-video"}], + ( + r"The `context.contextActivities.category` field should contain " + r"at least one valid `VideoProfileActivity`" + ), + ), ], ) -@custom_given(VideoContextContextActivities) -def test_models_xapi_video_context_activities_with_invalid_category( - category, context_activities -): +def test_models_xapi_video_context_activities_with_invalid_category(category, msg): """Test that an invalid `VideoContextContextActivities` should raise a `ValidationError`. """ + context_activities = mock_xapi_instance(VideoContextContextActivities) activities = json.loads(context_activities.json(exclude_none=True, by_alias=True)) activities["category"] = category - msg = ( - r"(The `context.contextActivities.category` field should contain at least one " - r"valid `VideoProfileActivity`) | (unexpected value)" - ) with pytest.raises(ValidationError, match=msg): VideoContextContextActivities(**activities) diff --git a/tests/models/xapi/test_virtual_classroom.py b/tests/models/xapi/test_virtual_classroom.py index b3eeadeb4..2f7f91601 100644 --- a/tests/models/xapi/test_virtual_classroom.py +++ b/tests/models/xapi/test_virtual_classroom.py @@ -3,8 +3,6 @@ import json import pytest -from hypothesis import settings -from hypothesis import strategies as st from pydantic import ValidationError from ralph.models.selector import ModelSelector @@ -29,10 +27,9 @@ VirtualClassroomUnsharedScreen, ) -from tests.fixtures.hypothesis_strategies import custom_builds, custom_given +from tests.factories import mock_xapi_instance -@settings(deadline=None) @pytest.mark.parametrize( "class_", [ @@ -53,21 +50,20 @@ VirtualClassroomStoppedCamera, ], ) -@custom_given(st.data()) -def test_models_xapi_virtual_classroom_selectors_with_valid_statements(class_, data): +def test_models_xapi_virtual_classroom_selectors_with_valid_statements(class_): """Test given a valid virtual classroom xAPI statement the `get_first_model` selector method should return the expected model. """ - statement = json.loads(data.draw(custom_builds(class_)).json()) + statement = json.loads(mock_xapi_instance(class_).model_dump_json()) model = ModelSelector(module="ralph.models.xapi").get_first_model(statement) assert model is class_ -@custom_given(VirtualClassroomInitialized) -def test_models_xapi_virtual_classroom_initialized_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_initialized_with_valid_statement(): """Test that a valid virtual classroom initialized statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomInitialized) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/initialized" assert ( statement.object.definition.type @@ -75,11 +71,11 @@ def test_models_xapi_virtual_classroom_initialized_with_valid_statement(statemen ) -@custom_given(VirtualClassroomJoined) -def test_models_xapi_virtual_classroom_joined_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_joined_with_valid_statement(): """Test that a virtual classroom joined statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomJoined) assert statement.verb.id == "http://activitystrea.ms/join" assert ( statement.object.definition.type @@ -87,11 +83,11 @@ def test_models_xapi_virtual_classroom_joined_with_valid_statement(statement): ) -@custom_given(VirtualClassroomLeft) -def test_models_xapi_virtual_classroom_left_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_left_with_valid_statement(): """Test that a virtual classroom left statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomLeft) assert statement.verb.id == "http://activitystrea.ms/leave" assert ( statement.object.definition.type @@ -99,11 +95,11 @@ def test_models_xapi_virtual_classroom_left_with_valid_statement(statement): ) -@custom_given(VirtualClassroomTerminated) -def test_models_xapi_virtual_classroom_terminated_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_terminated_with_valid_statement(): """Test that a virtual classroom terminated statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomTerminated) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/terminated" assert ( statement.object.definition.type @@ -111,11 +107,11 @@ def test_models_xapi_virtual_classroom_terminated_with_valid_statement(statement ) -@custom_given(VirtualClassroomMuted) -def test_models_xapi_virtual_classroom_muted_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_muted_with_valid_statement(): """Test that a virtual classroom muted statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomMuted) assert statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/muted" assert ( statement.object.definition.type @@ -123,11 +119,11 @@ def test_models_xapi_virtual_classroom_muted_with_valid_statement(statement): ) -@custom_given(VirtualClassroomUnmuted) -def test_models_xapi_virtual_classroom_unmuted_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_unmuted_with_valid_statement(): """Test that a virtual classroom unmuted statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomUnmuted) assert statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/unmuted" assert ( statement.object.definition.type @@ -135,11 +131,11 @@ def test_models_xapi_virtual_classroom_unmuted_with_valid_statement(statement): ) -@custom_given(VirtualClassroomSharedScreen) -def test_models_xapi_virtual_classroom_shared_screen_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_shared_screen_with_valid_statement(): """Test that a virtual classroom shared screen statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomSharedScreen) assert ( statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/shared-screen" @@ -150,11 +146,11 @@ def test_models_xapi_virtual_classroom_shared_screen_with_valid_statement(statem ) -@custom_given(VirtualClassroomUnsharedScreen) -def test_models_xapi_virtual_classroom_unshared_screen_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_unshared_screen_with_valid_statement(): """Test that a virtual classroom unshared screen statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomUnsharedScreen) assert ( statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/unshared-screen" @@ -165,11 +161,11 @@ def test_models_xapi_virtual_classroom_unshared_screen_with_valid_statement(stat ) -@custom_given(VirtualClassroomStartedCamera) -def test_models_xapi_virtual_classroom_started_camera_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_started_camera_with_valid_statement(): """Test that a virtual classroom started camera statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomStartedCamera) assert ( statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/started-camera" @@ -180,11 +176,11 @@ def test_models_xapi_virtual_classroom_started_camera_with_valid_statement(state ) -@custom_given(VirtualClassroomStoppedCamera) -def test_models_xapi_virtual_classroom_stopped_camera_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_stopped_camera_with_valid_statement(): """Test that a virtual classroom stopped camera statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomStoppedCamera) assert ( statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/stopped-camera" @@ -195,11 +191,11 @@ def test_models_xapi_virtual_classroom_stopped_camera_with_valid_statement(state ) -@custom_given(VirtualClassroomRaisedHand) -def test_models_xapi_virtual_classroom_raised_hand_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_raised_hand_with_valid_statement(): """Test that a virtual classroom raised hand statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomRaisedHand) assert ( statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/raised-hand" ) @@ -209,11 +205,11 @@ def test_models_xapi_virtual_classroom_raised_hand_with_valid_statement(statemen ) -@custom_given(VirtualClassroomLoweredHand) -def test_models_xapi_virtual_classroom_lowered_hand_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_lowered_hand_with_valid_statement(): """Test that a virtual classroom lowered hand statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomLoweredHand) assert ( statement.verb.id == "https://w3id.org/xapi/virtual-classroom/verbs/lowered-hand" @@ -224,11 +220,11 @@ def test_models_xapi_virtual_classroom_lowered_hand_with_valid_statement(stateme ) -@custom_given(VirtualClassroomStartedPoll) -def test_models_xapi_virtual_classroom_started_poll_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_started_poll_with_valid_statement(): """Test that a virtual classroom started poll statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomStartedPoll) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/asked" assert ( statement.object.definition.type @@ -236,11 +232,11 @@ def test_models_xapi_virtual_classroom_started_poll_with_valid_statement(stateme ) -@custom_given(VirtualClassroomAnsweredPoll) -def test_models_xapi_virtual_classroom_answered_poll_with_valid_statement(statement): +def test_models_xapi_virtual_classroom_answered_poll_with_valid_statement(): """Test that a virtual classroom answered poll statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ + statement = mock_xapi_instance(VirtualClassroomAnsweredPoll) assert statement.verb.id == "http://adlnet.gov/expapi/verbs/answered" assert ( statement.object.definition.type @@ -248,10 +244,8 @@ def test_models_xapi_virtual_classroom_answered_poll_with_valid_statement(statem ) -@custom_given(VirtualClassroomPostedPublicMessage) -def test_models_xapi_virtual_classroom_posted_public_message_with_valid_statement( - statement, -): +def test_models_xapi_virtual_classroom_posted_public_message_with_valid_statement(): + statement = mock_xapi_instance(VirtualClassroomPostedPublicMessage) """Test that a virtual classroom posted public message statement has the expected `verb`.`id` and `object`.`definition`.`type` property values. """ @@ -262,7 +256,6 @@ def test_models_xapi_virtual_classroom_posted_public_message_with_valid_statemen ) -@settings(deadline=None) @pytest.mark.parametrize( "category", [ @@ -275,13 +268,11 @@ def test_models_xapi_virtual_classroom_posted_public_message_with_valid_statemen [{"id": "https://foo.bar"}, {"id": "https://w3id.org/xapi/virtual-classroom"}], ], ) -@custom_given(VirtualClassroomContextContextActivities) -def test_models_xapi_virtual_classroom_context_activities_with_valid_category( - category, context_activities -): +def test_models_xapi_virtual_classroom_context_activities_with_valid_category(category): """Test that a valid `VirtualClassroomContextContextActivities` should not raise a `ValidationError`. """ + context_activities = mock_xapi_instance(VirtualClassroomContextContextActivities) activities = json.loads(context_activities.json(exclude_none=True, by_alias=True)) activities["category"] = category try: @@ -293,34 +284,49 @@ def test_models_xapi_virtual_classroom_context_activities_with_valid_category( ) -@settings(deadline=None) @pytest.mark.parametrize( - "category", + "category,msg", [ - {"id": "https://w3id.org/xapi/not-virtual-classroom"}, - { - "id": "https://w3id.org/xapi/virtual-classroom", - "definition": {"type": "http://adlnet.gov/expapi/activities/not-profile"}, - }, - [{"id": "https://w3id.org/xapi/not-virtual-classroom"}], - [ - {"id": "https://foo.bar"}, + ( {"id": "https://w3id.org/xapi/not-virtual-classroom"}, - ], + r"Input should be 'https://w3id.org/xapi/virtual-classroom'", + ), + ( + { + "id": "https://w3id.org/xapi/virtual-classroom", + "definition": { + "type": "http://adlnet.gov/expapi/activities/not-profile" + }, + }, + r"Input should be 'http://adlnet.gov/expapi/activities/profile'", + ), + ( + [{"id": "https://w3id.org/xapi/not-virtual-classroom"}], + ( + "The `context.contextActivities.category` field should contain " + "at least one valid `VirtualClassroomProfileActivity`" + ), + ), + ( + [ + {"id": "https://foo.bar"}, + {"id": "https://w3id.org/xapi/not-virtual-classroom"}, + ], + ( + "The `context.contextActivities.category` field should contain " + "at least one valid `VirtualClassroomProfileActivity`" + ), + ), ], ) -@custom_given(VirtualClassroomContextContextActivities) def test_models_xapi_virtual_classroom_context_activities_with_invalid_category( - category, context_activities + category, msg ): """Test that an invalid `VirtualClassroomContextContextActivities` should raise a `ValidationError`. """ + context_activities = mock_xapi_instance(VirtualClassroomContextContextActivities) activities = json.loads(context_activities.json(exclude_none=True, by_alias=True)) activities["category"] = category - msg = ( - r"(The `context.contextActivities.category` field should contain at least one " - r"valid `VirtualClassroomProfileActivity`) | (unexpected value)" - ) with pytest.raises(ValidationError, match=msg): VirtualClassroomContextContextActivities(**activities) diff --git a/tests/test_cli.py b/tests/test_cli.py index 82bae831a..e452429e4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,6 @@ from click.exceptions import BadParameter from click.testing import CliRunner from elasticsearch.helpers import bulk, scan -from hypothesis import settings as hypothesis_settings from pydantic import ValidationError from ralph import cli as cli_module @@ -29,13 +28,13 @@ from ralph.models.xapi.navigation.statements import PageTerminated from ralph.utils import iter_over_async +from tests.factories import mock_instance from tests.fixtures.backends import ( ES_TEST_HOSTS, ES_TEST_INDEX, WS_TEST_HOST, WS_TEST_PORT, ) -from tests.fixtures.hypothesis_strategies import custom_given test_logger = logging.getLogger("ralph") @@ -156,7 +155,7 @@ def test_cli_json_string_param_type_with_invalid_input(value): def test_cli_help_option(): """Test ralph --help command.""" runner = CliRunner() - result = runner.invoke(cli, ["--help"]) + result = runner.invoke(cli, ["--help"], catch_exceptions=False) assert result.exit_code == 0 assert ( @@ -473,21 +472,22 @@ def test_cli_extract_command_with_es_parser(): assert "\n".join([json.dumps({"id": idx}) for idx in range(10)]) in result.output -@custom_given(UIPageClose) -def test_cli_validate_command_with_edx_format(event): +def test_cli_validate_command_with_edx_format(): """Test ralph validate command using the edx format.""" - event_str = event.json() + event = mock_instance(UIPageClose) + + event_str = event.model_dump_json() runner = CliRunner() result = runner.invoke(cli, ["validate", "-f", "edx"], input=event_str) assert event_str in result.output -@hypothesis_settings(deadline=None) -@custom_given(UIPageClose) @pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) -def test_cli_convert_command_from_edx_to_xapi_format(valid_uuid, event): +def test_cli_convert_command_from_edx_to_xapi_format(valid_uuid): """Test ralph convert command from edx to xapi format.""" - event_str = event.json() + event = mock_instance(UIPageClose) + + event_str = event.model_dump_json() runner = CliRunner() command = f"-v ERROR convert -f edx -t xapi -u {valid_uuid} -p https://fun-mooc.fr" result = runner.invoke(cli, command.split(), input=event_str) @@ -622,10 +622,11 @@ def test_cli_read_command_with_es_backend(es): runner = CliRunner() es_hosts = ",".join(ES_TEST_HOSTS) es_client_options = "verify_certs=True" - command = f"""-v ERROR read -b es --es-hosts {es_hosts} + command = f"""-v ERROR read -b es --es-default-index {ES_TEST_INDEX} - --es-client-options {es_client_options}""" - result = runner.invoke(cli, command.split()) + --es-hosts {es_hosts} --es-client-options {es_client_options}""" + result = runner.invoke(cli, command.split(), catch_exceptions=False) + assert result.exit_code == 0 expected = ( "\n".join( @@ -653,8 +654,8 @@ def test_cli_read_command_client_options_with_es_backend(es): runner = CliRunner() es_client_options = "ca_certs=/path/,verify_certs=True" - command = f"""-v ERROR read -b es --es-client-options {es_client_options}""" - result = runner.invoke(cli, command.split()) + command = f"""-v DEBUG read -b es --es-client-options {es_client_options}""" + result = runner.invoke(cli, command.split(), catch_exceptions=True) assert result.exit_code == 1 assert "TLS options require scheme to be 'https'" in str(result.exception) @@ -721,11 +722,11 @@ def test_cli_read_command_with_es_backend_query(es): assert result.exit_code > 0 assert isinstance(result.exception, BackendParameterException) msg = ( - "Invalid MongoQuery query string: " - "[{'loc': ('__root__',), 'msg': 'Expecting value: line 1 column 1 (char 0)', " - "'type': 'value_error.jsondecode', 'ctx': {'msg': 'Expecting value', " - "'doc': 'wrong_query_string', 'pos': 0, 'lineno': 1, 'colno': 1}}]" + "Invalid MongoQuery query string: [{'type': 'value_error.jsondecode'," + " 'loc': ('__root__',), 'msg': 'Expecting value: line 1 column 1 (char 0)'," + " 'input': 'wrong_query_string'}]" ) + assert str(result.exception) == msg @@ -741,7 +742,12 @@ def websocket(): with websocket(): runner = CliRunner() uri = f"ws://{WS_TEST_HOST}:{WS_TEST_PORT}" - result = runner.invoke(cli, ["read", "-b", "async_ws", "--async-ws-uri", uri]) + result = runner.invoke( + cli, + ["read", "-b", "async_ws", "--async-ws-uri", uri], + catch_exceptions=False, + ) + assert result.exit_code == 0 assert "\n".join([json.dumps(event) for event in events]) in result.output @@ -962,10 +968,12 @@ def test_cli_write_command_with_es_backend(es): runner = CliRunner() es_hosts = ",".join(ES_TEST_HOSTS) + result = runner.invoke( cli, f"write -b es --es-hosts {es_hosts} --es-default-index {ES_TEST_INDEX}".split(), input="\n".join(json.dumps(record) for record in records), + catch_exceptions=False, ) assert result.exit_code == 0 diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py index dc4fd1524..3085911e3 100644 --- a/tests/test_cli_usage.py +++ b/tests/test_cli_usage.py @@ -107,6 +107,7 @@ def test_cli_read_command_usage(): result = runner.invoke(cli, ["read", "--help"]) assert result.exit_code == 0 + assert ( "Usage: ralph read [OPTIONS] [QUERY]\n\n" " Read records matching the QUERY (json or string) from a configured backend." @@ -120,14 +121,14 @@ def test_cli_read_command_usage(): " --async-es-allow-yellow-status / --no-async-es-allow-yellow-status\n" " --async-es-client-options KEY=VALUE,KEY=VALUE\n" " --async-es-default-index TEXT\n" - " --async-es-hosts VALUE1,VALUE2,VALUE3\n" + " --async-es-hosts TEXT\n" " --async-es-locale-encoding TEXT\n" " --async-es-point-in-time-keep-alive TEXT\n" " --async-es-read-chunk-size INTEGER\n" " --async-es-refresh-after-write TEXT\n" " --async-es-write-chunk-size INTEGER\n" " async_lrs backend: \n" - " --async-lrs-base-url TEXT\n" + " --async-lrs-base-url URL\n" " --async-lrs-headers KEY=VALUE,KEY=VALUE\n" " --async-lrs-locale-encoding TEXT\n" " --async-lrs-password TEXT\n" @@ -138,7 +139,7 @@ def test_cli_read_command_usage(): " --async-lrs-write-chunk-size INTEGER\n" " async_mongo backend: \n" " --async-mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --async-mongo-connection-uri MONGODSN\n" + " --async-mongo-connection-uri MULTIHOSTURL\n" " --async-mongo-default-collection TEXT\n" " --async-mongo-default-database TEXT\n" " --async-mongo-locale-encoding TEXT\n" @@ -148,7 +149,7 @@ def test_cli_read_command_usage(): " --async-ws-client-options KEY=VALUE,KEY=VALUE\n" " --async-ws-locale-encoding TEXT\n" " --async-ws-read-chunk-size INTEGER\n" - " --async-ws-uri TEXT\n" + " --async-ws-uri URL\n" " --async-ws-write-chunk-size INTEGER\n" " clickhouse backend: \n" " --clickhouse-client-options KEY=VALUE,KEY=VALUE\n" @@ -165,7 +166,7 @@ def test_cli_read_command_usage(): " --es-allow-yellow-status / --no-es-allow-yellow-status\n" " --es-client-options KEY=VALUE,KEY=VALUE\n" " --es-default-index TEXT\n" - " --es-hosts VALUE1,VALUE2,VALUE3\n" + " --es-hosts TEXT\n" " --es-locale-encoding TEXT\n" " --es-point-in-time-keep-alive TEXT\n" " --es-read-chunk-size INTEGER\n" @@ -189,7 +190,7 @@ def test_cli_read_command_usage(): " --ldp-service-name TEXT\n" " --ldp-write-chunk-size INTEGER\n" " lrs backend: \n" - " --lrs-base-url TEXT\n" + " --lrs-base-url URL\n" " --lrs-headers KEY=VALUE,KEY=VALUE\n" " --lrs-locale-encoding TEXT\n" " --lrs-password TEXT\n" @@ -200,7 +201,7 @@ def test_cli_read_command_usage(): " --lrs-write-chunk-size INTEGER\n" " mongo backend: \n" " --mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --mongo-connection-uri MONGODSN\n" + " --mongo-connection-uri MULTIHOSTURL\n" " --mongo-default-collection TEXT\n" " --mongo-default-database TEXT\n" " --mongo-locale-encoding TEXT\n" @@ -265,7 +266,7 @@ def test_cli_list_command_usage(): " --async-es-allow-yellow-status / --no-async-es-allow-yellow-status\n" " --async-es-client-options KEY=VALUE,KEY=VALUE\n" " --async-es-default-index TEXT\n" - " --async-es-hosts VALUE1,VALUE2,VALUE3\n" + " --async-es-hosts TEXT\n" " --async-es-locale-encoding TEXT\n" " --async-es-point-in-time-keep-alive TEXT\n" " --async-es-read-chunk-size INTEGER\n" @@ -273,7 +274,7 @@ def test_cli_list_command_usage(): " --async-es-write-chunk-size INTEGER\n" " async_mongo backend: \n" " --async-mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --async-mongo-connection-uri MONGODSN\n" + " --async-mongo-connection-uri MULTIHOSTURL\n" " --async-mongo-default-collection TEXT\n" " --async-mongo-default-database TEXT\n" " --async-mongo-locale-encoding TEXT\n" @@ -294,7 +295,7 @@ def test_cli_list_command_usage(): " --es-allow-yellow-status / --no-es-allow-yellow-status\n" " --es-client-options KEY=VALUE,KEY=VALUE\n" " --es-default-index TEXT\n" - " --es-hosts VALUE1,VALUE2,VALUE3\n" + " --es-hosts TEXT\n" " --es-locale-encoding TEXT\n" " --es-point-in-time-keep-alive TEXT\n" " --es-read-chunk-size INTEGER\n" @@ -319,7 +320,7 @@ def test_cli_list_command_usage(): " --ldp-write-chunk-size INTEGER\n" " mongo backend: \n" " --mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --mongo-connection-uri MONGODSN\n" + " --mongo-connection-uri MULTIHOSTURL\n" " --mongo-default-collection TEXT\n" " --mongo-default-database TEXT\n" " --mongo-locale-encoding TEXT\n" @@ -384,14 +385,14 @@ def test_cli_write_command_usage(): " --async-es-allow-yellow-status / --no-async-es-allow-yellow-status\n" " --async-es-client-options KEY=VALUE,KEY=VALUE\n" " --async-es-default-index TEXT\n" - " --async-es-hosts VALUE1,VALUE2,VALUE3\n" + " --async-es-hosts TEXT\n" " --async-es-locale-encoding TEXT\n" " --async-es-point-in-time-keep-alive TEXT\n" " --async-es-read-chunk-size INTEGER\n" " --async-es-refresh-after-write TEXT\n" " --async-es-write-chunk-size INTEGER\n" " async_lrs backend: \n" - " --async-lrs-base-url TEXT\n" + " --async-lrs-base-url URL\n" " --async-lrs-headers KEY=VALUE,KEY=VALUE\n" " --async-lrs-locale-encoding TEXT\n" " --async-lrs-password TEXT\n" @@ -402,7 +403,7 @@ def test_cli_write_command_usage(): " --async-lrs-write-chunk-size INTEGER\n" " async_mongo backend: \n" " --async-mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --async-mongo-connection-uri MONGODSN\n" + " --async-mongo-connection-uri MULTIHOSTURL\n" " --async-mongo-default-collection TEXT\n" " --async-mongo-default-database TEXT\n" " --async-mongo-locale-encoding TEXT\n" @@ -423,7 +424,7 @@ def test_cli_write_command_usage(): " --es-allow-yellow-status / --no-es-allow-yellow-status\n" " --es-client-options KEY=VALUE,KEY=VALUE\n" " --es-default-index TEXT\n" - " --es-hosts VALUE1,VALUE2,VALUE3\n" + " --es-hosts TEXT\n" " --es-locale-encoding TEXT\n" " --es-point-in-time-keep-alive TEXT\n" " --es-read-chunk-size INTEGER\n" @@ -436,7 +437,7 @@ def test_cli_write_command_usage(): " --fs-read-chunk-size INTEGER\n" " --fs-write-chunk-size INTEGER\n" " lrs backend: \n" - " --lrs-base-url TEXT\n" + " --lrs-base-url URL\n" " --lrs-headers KEY=VALUE,KEY=VALUE\n" " --lrs-locale-encoding TEXT\n" " --lrs-password TEXT\n" @@ -447,7 +448,7 @@ def test_cli_write_command_usage(): " --lrs-write-chunk-size INTEGER\n" " mongo backend: \n" " --mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --mongo-connection-uri MONGODSN\n" + " --mongo-connection-uri MULTIHOSTURL\n" " --mongo-default-collection TEXT\n" " --mongo-default-database TEXT\n" " --mongo-locale-encoding TEXT\n" @@ -515,7 +516,7 @@ def test_cli_runserver_command_usage(): " --async-es-allow-yellow-status / --no-async-es-allow-yellow-status\n" " --async-es-client-options KEY=VALUE,KEY=VALUE\n" " --async-es-default-index TEXT\n" - " --async-es-hosts VALUE1,VALUE2,VALUE3\n" + " --async-es-hosts TEXT\n" " --async-es-locale-encoding TEXT\n" " --async-es-point-in-time-keep-alive TEXT\n" " --async-es-read-chunk-size INTEGER\n" @@ -523,7 +524,7 @@ def test_cli_runserver_command_usage(): " --async-es-write-chunk-size INTEGER\n" " async_mongo backend: \n" " --async-mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --async-mongo-connection-uri MONGODSN\n" + " --async-mongo-connection-uri MULTIHOSTURL\n" " --async-mongo-default-collection TEXT\n" " --async-mongo-default-database TEXT\n" " --async-mongo-locale-encoding TEXT\n" @@ -545,7 +546,7 @@ def test_cli_runserver_command_usage(): " --es-allow-yellow-status / --no-es-allow-yellow-status\n" " --es-client-options KEY=VALUE,KEY=VALUE\n" " --es-default-index TEXT\n" - " --es-hosts VALUE1,VALUE2,VALUE3\n" + " --es-hosts TEXT\n" " --es-locale-encoding TEXT\n" " --es-point-in-time-keep-alive TEXT\n" " --es-read-chunk-size INTEGER\n" @@ -560,7 +561,7 @@ def test_cli_runserver_command_usage(): " --fs-write-chunk-size INTEGER\n" " mongo backend: \n" " --mongo-client-options KEY=VALUE,KEY=VALUE\n" - " --mongo-connection-uri MONGODSN\n" + " --mongo-connection-uri MULTIHOSTURL\n" " --mongo-default-collection TEXT\n" " --mongo-default-database TEXT\n" " --mongo-locale-encoding TEXT\n" diff --git a/tests/test_conf.py b/tests/test_conf.py index 9d30f4e8e..39dec2ada 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -3,6 +3,7 @@ from importlib import reload import pytest +from pydantic import ValidationError from ralph import conf from ralph.backends.data.es import ESDataBackend @@ -11,6 +12,7 @@ AuthBackends, CommaSeparatedTuple, Settings, + parse_obj_as, settings, ) from ralph.exceptions import ConfigurationException @@ -52,7 +54,7 @@ def test_conf_settings_field_value_priority(fs, monkeypatch): ) def test_conf_comma_separated_list_with_valid_values(value, expected, monkeypatch): """Test the CommaSeparatedTuple pydantic data type with valid values.""" - assert next(CommaSeparatedTuple.__get_validators__())(value) == expected + assert parse_obj_as(CommaSeparatedTuple, value) == expected monkeypatch.setenv("RALPH_BACKENDS__DATA__ES__HOSTS", "".join(value)) assert ESDataBackend().settings.HOSTS == expected @@ -60,8 +62,8 @@ def test_conf_comma_separated_list_with_valid_values(value, expected, monkeypatc @pytest.mark.parametrize("value", [{}, None]) def test_conf_comma_separated_list_with_invalid_values(value): """Test the CommaSeparatedTuple pydantic data type with invalid values.""" - with pytest.raises(TypeError, match="Invalid comma-separated list"): - next(CommaSeparatedTuple.__get_validators__())(value) + with pytest.raises(ValidationError, match="2 validation errors for function-after"): + parse_obj_as(CommaSeparatedTuple, value) @pytest.mark.parametrize( @@ -78,13 +80,13 @@ def test_conf_comma_separated_list_with_invalid_values(value): def test_conf_auth_backend(value, is_valid, expected, monkeypatch): """Test the AuthBackends data type with valid and invalid values.""" if is_valid: - assert next(AuthBackends.__get_validators__())(value) == expected + assert parse_obj_as(AuthBackends, value) == expected monkeypatch.setenv("RALPH_RUNSERVER_AUTH_BACKENDS", "".join(value)) reload(conf) assert conf.settings.RUNSERVER_AUTH_BACKENDS == expected else: with pytest.raises(ValueError, match="'notvalid' is not a valid AuthBackend"): - next(AuthBackends.__get_validators__())(value) + parse_obj_as(AuthBackends, value) def test_conf_core_settings_should_impact_settings_defaults(monkeypatch): @@ -94,7 +96,7 @@ def test_conf_core_settings_should_impact_settings_defaults(monkeypatch): reload(conf) # Configuration. - assert conf.Settings.Config.env_file_encoding == "ascii" + assert conf.Settings.model_config["env_file_encoding"] == "ascii" # Properties. assert str(conf.settings.APP_DIR) == "/foo" diff --git a/tests/test_utils.py b/tests/test_utils.py index 45e38414c..bdfa01f50 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -66,7 +66,7 @@ def __init__(self, settings): backend = ralph_utils.get_backend_instance(DummyTestBackend, options) assert isinstance(backend, DummyTestBackend) - assert backend.settings.dict() == expected + assert backend.settings.model_dump() == expected @pytest.mark.parametrize("path,value", [(["foo", "bar"], "bar_value")])