Skip to content

Commit 81d37d4

Browse files
committed
🏗️(project) migrate to pydantic v2 and switch tests to polyfactory
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`.
1 parent e55f98f commit 81d37d4

136 files changed

Lines changed: 2318 additions & 2000 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
- Upgrade `pydantic` to `2.5.3`
12+
- Migrate model tests from hypothesis strategies to polyfactory
13+
1114
## [4.0.0] - 2024-01-23
1215

1316
### Added

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ dependencies = [
3131
# By default, we only consider core dependencies required to use Ralph as a
3232
# library (mostly models).
3333
"langcodes>=3.2.0",
34-
"pydantic[dotenv,email]>=1.10.0, <2.0",
34+
"pydantic[email]>=2.5.3,<3.0",
35+
"pydantic_settings>=2.1.0,<3.0",
3536
"rfc3987>=1.3.0",
3637
]
3738
dynamic = ["version"]
@@ -91,7 +92,6 @@ dev = [
9192
"black==23.12.1",
9293
"cryptography==41.0.7",
9394
"factory-boy==3.3.0",
94-
"hypothesis<6.92.0", # pin as hypothesis 6.92.0 observability feature seems broken
9595
"logging-gelf==0.0.31",
9696
"mike==2.0.0",
9797
"mkdocs==1.5.3",
@@ -103,6 +103,7 @@ dev = [
103103
"neoteroi-mkdocs==1.0.4",
104104
"pyfakefs==5.3.2",
105105
"pymdown-extensions==10.7",
106+
"polyfactory==2.14.1",
106107
"pytest==7.4.4",
107108
"pytest-asyncio==0.23.3",
108109
"pytest-cov==4.1.0",

src/ralph/api/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,7 @@ async def whoami(
5050
user: AuthenticatedUser = Depends(get_authenticated_user),
5151
) -> Dict[str, Any]:
5252
"""Return the current user's username along with their scopes."""
53-
return {"agent": user.agent, "scopes": user.scopes}
53+
return {
54+
"agent": user.agent.model_dump(mode="json", exclude_none=True),
55+
"scopes": user.scopes,
56+
}

src/ralph/api/auth/basic.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Basic authentication & authorization related tools for the Ralph API."""
22

33
import logging
4+
import os
45
from functools import lru_cache
56
from pathlib import Path
67
from threading import Lock
@@ -10,7 +11,7 @@
1011
from cachetools import TTLCache, cached
1112
from fastapi import Depends, HTTPException, status
1213
from fastapi.security import HTTPBasic, HTTPBasicCredentials
13-
from pydantic import BaseModel, root_validator
14+
from pydantic import RootModel, model_validator
1415
from starlette.authentication import AuthenticationError
1516

1617
from ralph.api.auth.user import AuthenticatedUser
@@ -40,45 +41,42 @@ class UserCredentials(AuthenticatedUser):
4041
username: str
4142

4243

43-
class ServerUsersCredentials(BaseModel):
44+
class ServerUsersCredentials(RootModel[List[UserCredentials]]):
4445
"""Custom root pydantic model.
4546
4647
Describe expected list of all server users credentials as stored in
4748
the credentials file.
4849
4950
Attributes:
50-
__root__ (List): Custom root consisting of the
51+
root (List): Custom root consisting of the
5152
list of all server users credentials.
5253
"""
5354

54-
__root__: List[UserCredentials]
55-
5655
def __add__(self, other) -> Any: # noqa: D105
57-
return ServerUsersCredentials.parse_obj(self.__root__ + other.__root__)
56+
return ServerUsersCredentials.model_validate(self.root + other.root)
5857

5958
def __getitem__(self, item: int) -> UserCredentials: # noqa: D105
60-
return self.__root__[item]
59+
return self.root[item]
6160

6261
def __len__(self) -> int: # noqa: D105
63-
return len(self.__root__)
62+
return len(self.root)
6463

6564
def __iter__(self) -> Iterator[UserCredentials]: # noqa: D105
66-
return iter(self.__root__)
65+
return iter(self.root)
6766

68-
@root_validator
69-
@classmethod
70-
def ensure_unique_username(cls, values: Any) -> Any:
67+
@model_validator(mode="after")
68+
def ensure_unique_username(self) -> Any:
7169
"""Every username should be unique among registered users."""
72-
usernames = [entry.username for entry in values.get("__root__")]
70+
usernames = [entry.username for entry in self.root]
7371
if len(usernames) != len(set(usernames)):
7472
raise ValueError(
7573
"You cannot create multiple credentials with the same username"
7674
)
77-
return values
75+
return self
7876

7977

8078
@lru_cache()
81-
def get_stored_credentials(auth_file: Path) -> ServerUsersCredentials:
79+
def get_stored_credentials(auth_file: os.PathLike) -> ServerUsersCredentials:
8280
"""Helper to read the credentials/scopes file.
8381
8482
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:
9694
msg = "Credentials file <%s> not found."
9795
logger.warning(msg, auth_file)
9896
raise AuthenticationError(msg.format(auth_file))
99-
return ServerUsersCredentials.parse_file(auth_file)
97+
98+
with open(auth_file, encoding=settings.LOCALE_ENCODING) as f:
99+
return ServerUsersCredentials.model_validate_json(f.read())
100100

101101

102102
@cached(

src/ralph/api/auth/oidc.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from fastapi.security import HTTPBearer, OpenIdConnect
1010
from jose import ExpiredSignatureError, JWTError, jwt
1111
from jose.exceptions import JWTClaimsError
12-
from pydantic import AnyUrl, BaseModel, Extra
12+
from pydantic import AnyUrl, BaseModel, ConfigDict
1313
from typing_extensions import Annotated
1414

1515
from ralph.api.auth.user import AuthenticatedUser, UserScopes
@@ -44,13 +44,11 @@ class IDToken(BaseModel):
4444

4545
iss: str
4646
sub: str
47-
aud: Optional[str]
47+
aud: Optional[str] = None
4848
exp: int
4949
iat: int
50-
scope: Optional[str]
51-
52-
class Config: # noqa: D106
53-
extra = Extra.ignore
50+
scope: Optional[str] = None
51+
model_config = ConfigDict(extra="ignore")
5452

5553

5654
@lru_cache()
@@ -142,7 +140,7 @@ def get_oidc_user(
142140
headers={"WWW-Authenticate": "Bearer"},
143141
) from exc
144142

145-
id_token = IDToken.parse_obj(decoded_token)
143+
id_token = IDToken.model_validate(decoded_token)
146144

147145
user = AuthenticatedUser(
148146
agent={"openid": f"{id_token.iss}/{id_token.sub}"},

src/ralph/api/auth/user.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Authenticated user for the Ralph API."""
22

3-
from typing import Dict, FrozenSet, Literal
3+
from typing import FrozenSet, Literal
44

5-
from pydantic import BaseModel
5+
from pydantic import BaseModel, RootModel
6+
7+
from ralph.models.xapi.base.agents import BaseXapiAgent
68

79
Scope = Literal[
810
"statements/write",
@@ -18,7 +20,7 @@
1820
]
1921

2022

21-
class UserScopes(FrozenSet[Scope]):
23+
class UserScopes(RootModel[FrozenSet[Scope]]):
2224
"""Scopes available to users."""
2325

2426
def is_authorized(self, requested_scope: Scope):
@@ -47,19 +49,11 @@ def is_authorized(self, requested_scope: Scope):
4749
}
4850

4951
expanded_user_scopes = set()
50-
for scope in self:
52+
for scope in self.root:
5153
expanded_user_scopes.update(expanded_scopes.get(scope, {scope}))
5254

5355
return requested_scope in expanded_user_scopes
5456

55-
@classmethod
56-
def __get_validators__(cls): # noqa: D105
57-
def validate(value: FrozenSet[Scope]):
58-
"""Transform value to an instance of UserScopes."""
59-
return cls(value)
60-
61-
yield validate
62-
6357

6458
class AuthenticatedUser(BaseModel):
6559
"""Pydantic model for user authentication.
@@ -69,5 +63,5 @@ class AuthenticatedUser(BaseModel):
6963
scopes (list): The scopes the user has access to.
7064
"""
7165

72-
agent: Dict
66+
agent: BaseXapiAgent
7367
scopes: UserScopes

src/ralph/api/forwarding.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,16 @@ async def forward_xapi_statements(
4242
try:
4343
# NB: post or put
4444
req = await getattr(client, method)(
45-
forwarding.url,
45+
str(forwarding.url),
4646
json=statements,
4747
auth=(forwarding.basic_username, forwarding.basic_password),
4848
timeout=forwarding.timeout,
4949
)
5050
req.raise_for_status()
5151
msg = "Forwarded %s statements to %s with success."
5252
if isinstance(statements, list):
53-
logger.debug(msg, len(statements), forwarding.url)
53+
logger.debug(msg, len(statements), str(forwarding.url))
5454
else:
55-
logger.debug(msg, 1, forwarding.url)
55+
logger.debug(msg, 1, str(forwarding.url))
5656
except (RequestError, HTTPStatusError) as error:
5757
logger.error("Failed to forward xAPI statements. %s", error)

src/ralph/api/models.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Optional, Union
77
from uuid import UUID
88

9-
from pydantic import AnyUrl, BaseModel, Extra
9+
from pydantic import AnyUrl, BaseModel, ConfigDict
1010

1111
from ..models.xapi.base.agents import BaseXapiAgent
1212
from ..models.xapi.base.groups import BaseXapiGroup
@@ -29,13 +29,7 @@ class BaseModelWithLaxConfig(BaseModel):
2929
we receive statements through the API.
3030
"""
3131

32-
class Config:
33-
"""Enable extra properties.
34-
35-
Useful for not having to perform comprehensive validation.
36-
"""
37-
38-
extra = Extra.allow
32+
model_config = ConfigDict(extra="allow", coerce_numbers_to_str=True)
3933

4034

4135
class LaxObjectField(BaseModelWithLaxConfig):
@@ -64,6 +58,6 @@ class LaxStatement(BaseModelWithLaxConfig):
6458
"""
6559

6660
actor: Union[BaseXapiAgent, BaseXapiGroup]
67-
id: Optional[UUID]
61+
id: Optional[UUID] = None
6862
object: LaxObjectField
6963
verb: LaxVerbField

src/ralph/api/routers/health.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async def heartbeat(response: Response) -> Heartbeat:
4747
4848
Return a 200 if all checks are successful.
4949
"""
50-
statuses = Heartbeat.construct(
50+
statuses = Heartbeat.model_construct(
5151
database=await await_if_coroutine(BACKEND_CLIENT.status())
5252
)
5353
if not statuses.is_alive:

src/ralph/api/routers/statements.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from fastapi import (
1111
APIRouter,
1212
BackgroundTasks,
13+
Body,
1314
Depends,
1415
HTTPException,
1516
Query,
@@ -19,7 +20,7 @@
1920
status,
2021
)
2122
from fastapi.dependencies.models import Dependant
22-
from pydantic import parse_obj_as
23+
from pydantic import TypeAdapter
2324
from pydantic.types import Json
2425
from typing_extensions import Annotated
2526

@@ -98,14 +99,17 @@ def _enrich_statement_with_authority(
9899
) -> None:
99100
# authority: Information about whom or what has asserted the statement is true.
100101
# https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#249-authority
101-
statement["authority"] = current_user.agent
102+
statement["authority"] = current_user.agent.model_dump(
103+
exclude_none=True, mode="json"
104+
)
102105

103106

104107
def _parse_agent_parameters(agent_obj: dict) -> AgentParameters:
105108
"""Parse a dict and return an AgentParameters object to use in queries."""
106109
# Transform agent to `dict` as FastAPI cannot parse JSON (seen as string)
107110

108-
agent = parse_obj_as(BaseXapiAgent, agent_obj)
111+
adapter = TypeAdapter(BaseXapiAgent)
112+
agent = adapter.validate_python(agent_obj)
109113

110114
agent_query_params = {}
111115
if isinstance(agent, BaseXapiAgentWithMbox):
@@ -119,7 +123,7 @@ def _parse_agent_parameters(agent_obj: dict) -> AgentParameters:
119123
agent_query_params["account__home_page"] = agent.account.homePage
120124

121125
# Overwrite `agent` field
122-
return AgentParameters.construct(**agent_query_params)
126+
return AgentParameters.model_construct(**agent_query_params)
123127

124128

125129
def strict_query_params(request: Request) -> None:
@@ -141,7 +145,7 @@ def strict_query_params(request: Request) -> None:
141145

142146
@router.get("")
143147
@router.get("/")
144-
async def get( # noqa: PLR0913
148+
async def get( # noqa: PLR0912,PLR0913
145149
request: Request,
146150
current_user: Annotated[
147151
AuthenticatedUser,
@@ -169,7 +173,7 @@ async def get( # noqa: PLR0913
169173
None,
170174
description="Filter, only return Statements matching the specified Verb id",
171175
),
172-
activity: Optional[IRI] = Query(
176+
activity: Optional[Annotated[IRI, Body()]] = Query(
173177
None,
174178
description=(
175179
"Filter, only return Statements for which the Object "
@@ -334,7 +338,14 @@ async def get( # noqa: PLR0913
334338
# Overwrite `agent` field
335339
query_params["agent"] = _parse_agent_parameters(
336340
json.loads(query_params["agent"])
337-
)
341+
).model_dump(mode="json", exclude_none=True)
342+
343+
# Coerce `verb` and `activity` as IRI
344+
if query_params.get("verb"):
345+
query_params["verb"] = IRI(query_params["verb"])
346+
347+
if query_params.get("activity"):
348+
query_params["activity"] = IRI(query_params["activity"])
338349

339350
# mine: If using scopes, only restrict users with limited scopes
340351
if settings.LRS_RESTRICT_BY_SCOPES:
@@ -346,7 +357,9 @@ async def get( # noqa: PLR0913
346357

347358
# Filter by authority if using `mine`
348359
if mine:
349-
query_params["authority"] = _parse_agent_parameters(current_user.agent)
360+
query_params["authority"] = _parse_agent_parameters(
361+
current_user.agent.model_dump(mode="json")
362+
).model_dump(mode="json", exclude_none=True)
350363

351364
if "mine" in query_params:
352365
query_params.pop("mine")
@@ -355,7 +368,7 @@ async def get( # noqa: PLR0913
355368
try:
356369
query_result = await await_if_coroutine(
357370
BACKEND_CLIENT.query_statements(
358-
RalphStatementsQuery.construct(**{**query_params, "limit": limit})
371+
RalphStatementsQuery.model_construct(**{**query_params, "limit": limit})
359372
)
360373
)
361374
except BackendException as error:
@@ -415,7 +428,7 @@ async def put(
415428
LRS Specification:
416429
https://github.com/adlnet/xAPI-Spec/blob/1.0.3/xAPI-Communication.md#211-put-statements
417430
"""
418-
statement_as_dict = statement.dict(exclude_unset=True)
431+
statement_as_dict = statement.model_dump(exclude_unset=True, mode="json")
419432
statement_id = str(statement_id)
420433

421434
statement_as_dict.update(id=str(statement_as_dict.get("id", statement_id)))
@@ -504,7 +517,9 @@ async def post( # noqa: PLR0912
504517

505518
# Enrich statements before forwarding
506519
statements_dict = {}
507-
for statement in (x.dict(exclude_unset=True) for x in statements):
520+
for statement in (
521+
x.model_dump(exclude_unset=True, mode="json") for x in statements
522+
):
508523
_enrich_statement_with_id(statement)
509524
# Requests with duplicate statement IDs are considered invalid
510525
if statement["id"] in statements_dict:

0 commit comments

Comments
 (0)