Skip to content

Commit 1fb2f58

Browse files
committed
refactor: improve timestamp pydantic logic
1 parent 423c1bd commit 1fb2f58

11 files changed

Lines changed: 91 additions & 124 deletions

File tree

examples/simple_discord_bot.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async def cog_slash_command_error( # noqa: PLR6301
3838
)
3939
case faceit.exceptions.APIError():
4040
await inter.edit_original_response(
41-
f"⚠️ API Error [{error.status_code}]: {error.message}"
41+
f"⚠️ API Error (`{error.status_code}`): {error.message}"
4242
)
4343
case _:
4444
await inter.edit_original_response(
@@ -63,8 +63,7 @@ async def stats(
6363
cs2_game = player.games.get(faceit.GameID.CS2)
6464
if cs2_game is None:
6565
return await inter.edit_original_response(
66-
f"🔎 Player **{player.nickname}** found, "
67-
"but they don't have CS2 linked."
66+
f"🔎 Player `{player.nickname}` found, but they don't have CS2 linked."
6867
)
6968

7069
player_stats = await self.faceit_data.players.stats(

pyproject.toml

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,30 @@ classifiers = [
2323
"Typing :: Typed",
2424
]
2525
dependencies = [
26-
"httpx>=0.27.0,<1.0.0",
27-
"pydantic>=2.7.1,<3.0.0",
28-
"tenacity>=8.2.3,<9.0.0",
26+
"httpx>=0.28.0",
27+
"pydantic>=2.13.0",
28+
"tenacity>=8.5.0",
2929
]
3030

3131
[project.optional-dependencies]
3232
env = ["python-decouple>=3.8"]
3333

3434
[dependency-groups]
3535
dev = [
36-
"mypy>=1.14",
37-
"pre-commit==3.5",
38-
"pytest>=7.4",
39-
"pytest-asyncio>=0.21.1",
40-
"ruff>=0.4.8",
36+
"mypy>=2.0.0",
37+
"pre-commit>=3.5",
38+
"pytest>=9.0.0",
39+
"pytest-asyncio>=1.3.0",
40+
"ruff>=0.15.0",
4141
]
4242
examples = [
43-
"disnake>=2.0.0,<3.0.0",
43+
"disnake>=2.0.0",
4444
]
4545

4646
[project.urls]
47-
"Documentation" = "https://docs.faceit.com/docs"
47+
"Bug Tracker" = "https://github.com/zombyacoff/faceit-python/issues"
48+
"Releases" = "https://github.com/zombyacoff/faceit-python/releases"
49+
"Repository" = "https://github.com/zombyacoff/faceit-python"
4850

4951
[build-system]
5052
requires = ["uv_build>=0.11.14"]

src/faceit/api/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
RawAPIPageResponse,
2929
RawAPIResponse,
3030
)
31-
from faceit.utils import warn_stacklevel
31+
from faceit.utils import find_user_stacklevel
3232

3333
if TYPE_CHECKING:
3434
from collections.abc import Mapping
@@ -172,7 +172,7 @@ def _validate_response(
172172
"unprocessed data."
173173
)
174174
msg = default_warn_msg if warn_msg is None else warn_msg
175-
warnings.warn(msg, stacklevel=warn_stacklevel())
175+
warnings.warn(msg, stacklevel=find_user_stacklevel())
176176
return response
177177
try:
178178
return validator.model_validate(response)

src/faceit/api/data/matches.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,9 @@
1818
)
1919

2020
_MatchID: TypeAlias = str
21-
# We use `AfterValidator` with the `_MatchID` type alias instead of `FaceitMatchID` directly
22-
# to avoid mypy complaints. Mypy cannot fully recognize our custom type as compatible
23-
# with str, so this approach ensures proper type checking and validation.
2421
_MatchIDValidated: TypeAlias = Annotated[
25-
_MatchID, AfterValidator(FaceitMatchID._validate)
22+
_MatchID,
23+
AfterValidator(FaceitMatchID),
2624
]
2725

2826

src/faceit/api/pagination.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,22 @@
4545
deduplicate_unhashable,
4646
deep_get,
4747
extends,
48+
find_user_stacklevel,
4849
representation,
4950
validate_positive_int,
50-
warn_stacklevel,
5151
)
5252

53-
_PageType: TypeAlias = ItemPage[Any] | RawAPIPageResponse
54-
_PageList: TypeAlias = list[_PageType]
55-
_PageT = TypeVar("_PageT", bound=_PageType)
53+
ProcessedPages: TypeAlias = list[RawAPIItem] | ItemPage[_T]
54+
_PageType: TypeAlias = RawAPIPageResponse | ItemPage[_T]
55+
_PageList: TypeAlias = list[_PageType[_T]]
56+
_PageT = TypeVar("_PageT", bound=_PageType[Any])
5657

5758

5859
if TYPE_CHECKING:
59-
_PageClass: TypeAlias = type[ItemPage[Any] | RawAPIPageResponse]
60-
_PageFactory: TypeAlias = Callable[[_PageList], _PageClass]
61-
_PageFactoryMap: TypeAlias = Mapping["CollectReturnFormat", _PageFactory]
60+
_PageFactoryMap: TypeAlias = Mapping[
61+
"CollectReturnFormat",
62+
Callable[[_PageList[Any]], type[RawAPIPageResponse | ItemPage[Any]]],
63+
]
6264
_OptionalTimestampPaginationConfig: TypeAlias = (
6365
"TimestampPaginationConfig | Literal[False]"
6466
)
@@ -336,7 +338,7 @@ def warn_if_exceeds_safe(max_pages: int, /) -> int:
336338
f"The computed number of pages ({max_pages}) exceeds the "
337339
f"recommended safe maximum ({self.__class__.SAFE_MAX_PAGES}). "
338340
"Proceed at your own risk.",
339-
stacklevel=warn_stacklevel(),
341+
stacklevel=find_user_stacklevel(),
340342
)
341343
return max_pages
342344

@@ -396,7 +398,7 @@ def _remove_pagination_args(**kwargs: _T) -> dict[str, _T]:
396398
f"Pagination parameters {_PAGINATION_ARGS} should not be "
397399
"provided by users. These parameters are managed internally "
398400
"by the pagination system.",
399-
stacklevel=warn_stacklevel(),
401+
stacklevel=find_user_stacklevel(),
400402
)
401403
return kwargs
402404

@@ -424,7 +426,7 @@ def _validate_unix_config(
424426

425427
@staticmethod
426428
def _extract_unix_timestamp(
427-
cfg: TimestampPaginationConfig, page: _PageType | None, /
429+
cfg: TimestampPaginationConfig, page: _PageType[Any] | None, /
428430
) -> int | None:
429431
if not page:
430432
return None
@@ -460,16 +462,16 @@ def _validate_unix_pagination_parameter(
460462
warnings.warn(
461463
"The parameters 'start' and 'to' will be managed automatically with Unix "
462464
"timestamp pagination. Your provided values will be ignored.",
463-
stacklevel=warn_stacklevel(),
465+
stacklevel=find_user_stacklevel(),
464466
)
465467

466468
@classmethod
467469
def _process_collected_pages(
468470
cls,
469-
collection: list[RawAPIPageResponse | ItemPage[_T]],
471+
collection: _PageList[_T],
470472
return_format: CollectReturnFormat,
471473
deduplicate: bool, # noqa: FBT001
472-
) -> list[RawAPIItem] | ItemPage[_T]:
474+
) -> ProcessedPages[_T]:
473475
if cls._COLLECT_RETURN_FORMATS[return_format](collection) is dict:
474476
raw = chain.from_iterable(
475477
p[RAW_RESPONSE_ITEMS_KEY] for p in collection if isinstance(p, dict)
@@ -481,7 +483,7 @@ def _process_collected_pages(
481483
@classmethod
482484
def _deduplicate_collection(
483485
cls, collection: Iterable[RawAPIItem] | ItemPage[_T], /
484-
) -> list[RawAPIItem] | ItemPage[_T]:
486+
) -> ProcessedPages[_T]:
485487
if not isinstance(collection, ItemPage):
486488
return deduplicate_unhashable(collection)
487489
return collection.with_items(deduplicate_unhashable(collection)) # pyright: ignore[reportArgumentType, reportReturnType]
@@ -577,7 +579,7 @@ def collect(
577579
self: SyncPageIterator[RawAPIPageResponse] | SyncPageIterator[ItemPage[_T]],
578580
*,
579581
deduplicate: bool = True,
580-
) -> list[RawAPIItem] | ItemPage[_T]:
582+
) -> ProcessedPages[_T]:
581583
return self.__class__.gather_from_iterator(self, deduplicate=deduplicate)
582584

583585
@classmethod
@@ -649,7 +651,7 @@ def gather_from_iterator(
649651
return_format: CollectReturnFormat = CollectReturnFormat.FIRST,
650652
*,
651653
deduplicate: bool = True,
652-
) -> list[RawAPIItem] | ItemPage[_T]:
654+
) -> ProcessedPages[_T]:
653655
return cls._process_collected_pages(list(iterator), return_format, deduplicate)
654656

655657

@@ -669,7 +671,7 @@ async def collect(
669671

670672
async def collect(
671673
self: AsyncPageIterator[RawAPIPageResponse] | AsyncPageIterator[ItemPage[_T]],
672-
) -> list[RawAPIItem] | ItemPage[_T]:
674+
) -> ProcessedPages[_T]:
673675
return await self.__class__.gather_from_iterator(self)
674676

675677
@classmethod
@@ -741,7 +743,7 @@ async def gather_from_iterator(
741743
return_format: CollectReturnFormat = CollectReturnFormat.FIRST,
742744
*,
743745
deduplicate: bool = True,
744-
) -> list[RawAPIItem] | ItemPage[_T]:
746+
) -> ProcessedPages[_T]:
745747
return cls._process_collected_pages(
746748
[page async for page in iterator], return_format, deduplicate
747749
)

src/faceit/http/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ def close(cls) -> Never:
515515
Async clients should use :meth:`~.aclose` instead.
516516
"""
517517
msg = (
518-
f"Use 'await {cls.__name__}.aclose()' instead of '{cls.__name__}.close().'"
518+
f"Use 'await {cls.__name__}.aclose()' instead of '{cls.__name__}.close()'."
519519
)
520520
raise RuntimeError(msg)
521521

src/faceit/models/custom_types/common.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from pydantic import (
1818
AfterValidator,
1919
BeforeValidator,
20-
GetCoreSchemaHandler,
2120
RootModel,
2221
model_validator,
2322
)
@@ -29,7 +28,6 @@
2928
if TYPE_CHECKING:
3029
from collections.abc import ItemsView, Iterator, KeysView, ValuesView
3130

32-
_INJECTED_KEY: Final = "injected_key"
3331
_LANG_PLACEHOLDER: Final = "{lang}"
3432
_LANG_PATTERN: Final = re.compile(rf"/?{re.escape(_LANG_PLACEHOLDER)}/?")
3533

@@ -71,17 +69,11 @@ def from_datetime(cls, dt: datetime, /) -> Self:
7169
return cls(round(dt.timestamp() * cls._UNITS_PER_SEC))
7270

7371
@classmethod
74-
def _validate(cls, value: int, /) -> Self:
75-
if value >= 0:
76-
return cls(value)
77-
msg = f"Value {value} is negative. Timestamp cannot be negative"
78-
raise ValueError(msg)
79-
80-
@classmethod
81-
def __get_pydantic_core_schema__(
82-
cls, _: type[Any], handler: GetCoreSchemaHandler
83-
) -> core_schema.CoreSchema:
84-
return core_schema.no_info_after_validator_function(cls._validate, handler(int))
72+
def __get_pydantic_core_schema__(cls, *_: Any, **__: Any) -> core_schema.CoreSchema:
73+
return core_schema.chain_schema([
74+
core_schema.int_schema(ge=0),
75+
core_schema.no_info_after_validator_function(cls, core_schema.any_schema()),
76+
])
8577

8678

8779
@final
@@ -103,6 +95,9 @@ def as_ms(self) -> TimestampMs:
10395
NotStrictTimestampSec: TypeAlias = TimestampSec | int
10496

10597

98+
_INJECTED_KEY: Final = "injected_key"
99+
100+
106101
@final
107102
class ResponseContainer(RootModel[dict[str, _T]]):
108103
__slots__ = ()
@@ -128,7 +123,7 @@ def get(self, key: str, /, default: _R | None = None) -> _T | _R | None:
128123
def __getattr__(self, name: str) -> _T:
129124
if name in self.root:
130125
return self.root[name]
131-
msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
126+
msg = f"{self.__class__.__name__!r} object has no attribute {name!r}"
132127
raise AttributeError(msg)
133128

134129
def __iter__(self) -> Iterator[str]: # type: ignore[override]

src/faceit/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@
6363
"start": int,
6464
"end": int,
6565
# Unix timestamps (in milliseconds)
66-
"from": NotRequired["TimestampMs"],
67-
"to": NotRequired["TimestampMs"],
66+
"from": NotRequired[int],
67+
"to": NotRequired[int],
6868
},
6969
)
7070
RawAPIResponse: TypeAlias = RawAPIItem | RawAPIPageResponse

src/faceit/utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,10 @@ def validate_positive_int(value: Any, /, param_name: str = "value") -> int:
198198
impractical or unavailable.
199199
"""
200200
if not isinstance(value, int):
201-
msg = f"'{param_name}' must be int, got {type(value).__name__}"
201+
msg = f"{param_name!r} must be int, got {type(value).__name__}"
202202
raise TypeError(msg)
203203
if value <= 0:
204-
msg = f"'{param_name}' must be a positive integer, got {value}"
204+
msg = f"{param_name!r} must be a positive integer, got {value}"
205205
raise ValueError(msg)
206206
return value
207207

@@ -233,7 +233,7 @@ def _get_ignored_paths() -> tuple[
233233
return tuple(prefixes), frozenset(files)
234234

235235

236-
def warn_stacklevel() -> int:
236+
def find_user_stacklevel() -> int:
237237
"""
238238
Determines the appropriate stack level for warnings emitted by the library,
239239
so that they point to the user's code instead of internal library frames.

0 commit comments

Comments
 (0)