Skip to content

Commit 90be668

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 90be668

File tree

138 files changed

+2315
-2008
lines changed

Some content is hidden

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

138 files changed

+2315
-2008
lines changed

docs/CHANGELOG.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/LICENSE.md

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs/commands.md

Lines changed: 0 additions & 6 deletions
This file was deleted.

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

0 commit comments

Comments
 (0)