Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ on:
branches: [main]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Run pre-commit
uses: pre-commit/action@v3.0.1

test:
runs-on: ubuntu-latest
strategy:
Expand All @@ -27,14 +40,5 @@ jobs:
- name: Install dependencies
run: uv sync --all-extras

- name: Run ruff check
run: uv run ruff check src/

- name: Run ruff format check
run: uv run ruff format --check src/

- name: Run mypy
run: uv run mypy src/

- name: Run tests
run: uv run pytest -v
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@ repos:
- id: check-yaml
- id: check-added-large-files
- id: check-toml

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.14.1
hooks:
- id: mypy
pass_filenames: false
additional_dependencies:
- pydantic>=2.0.0
- httpx>=0.27.0
- pytest>=8.0.0
6 changes: 3 additions & 3 deletions examples/leaderboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
phase_lb = mcsrranked.leaderboards.phase()
if phase_lb.phase.number:
print(f"Phase {phase_lb.phase.number}")
for player in phase_lb.users[:5]:
points = player.season_result.phase_point
print(f"{player.nickname}: {points} points")
for phase_player in phase_lb.users[:5]:
points = phase_player.season_result.phase_point
print(f"{phase_player.nickname}: {points} points")
print()

# Record leaderboard (best times)
Expand Down
8 changes: 4 additions & 4 deletions examples/matches.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def format_time(ms: int) -> str:

print("Elo Changes:")
for change in match.changes:
player = next((p for p in match.players if p.uuid == change.uuid), None)
name = player.nickname if player else change.uuid
found_player = next((p for p in match.players if p.uuid == change.uuid), None)
name = found_player.nickname if found_player else change.uuid
change_str = (
f"+{change.change}"
if change.change and change.change > 0
Expand All @@ -67,6 +67,6 @@ def format_time(ms: int) -> str:
if match.timelines:
print("Timeline:")
for event in match.timelines[:10]:
player = next((p for p in match.players if p.uuid == event.uuid), None)
name = player.nickname if player else event.uuid[:8]
found_player = next((p for p in match.players if p.uuid == event.uuid), None)
name = found_player.nickname if found_player else event.uuid[:8]
print(f" {format_time(event.time)} - {name}: {event.type}")
12 changes: 6 additions & 6 deletions examples/versus.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ def format_time(ms: int) -> str:
total = ranked.get("total", 0)
for uuid, wins in ranked.items():
if uuid != "total":
player = next((p for p in stats.players if p.uuid == uuid), None)
name = player.nickname if player else uuid[:8]
found_player = next((p for p in stats.players if p.uuid == uuid), None)
name = found_player.nickname if found_player else uuid[:8]
print(f" {name}: {wins} wins")
print(f" Total matches: {total}")
print()
Expand All @@ -42,17 +42,17 @@ def format_time(ms: int) -> str:
total = casual.get("total", 0)
for uuid, wins in casual.items():
if uuid != "total":
player = next((p for p in stats.players if p.uuid == uuid), None)
name = player.nickname if player else uuid[:8]
found_player = next((p for p in stats.players if p.uuid == uuid), None)
name = found_player.nickname if found_player else uuid[:8]
print(f" {name}: {wins} wins")
print(f" Total matches: {total}")
print()

# Display elo changes
print("Total Elo Changes:")
for uuid, change in stats.changes.items():
player = next((p for p in stats.players if p.uuid == uuid), None)
name = player.nickname if player else uuid[:8]
found_player = next((p for p in stats.players if p.uuid == uuid), None)
name = found_player.nickname if found_player else uuid[:8]
sign = "+" if change > 0 else ""
print(f" {name}: {sign}{change}")
print()
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ quote-style = "double"
indent-style = "space"

[tool.mypy]
files = ["src", "tests", "examples"]
python_version = "3.11"
strict = true
warn_return_any = true
Expand Down
13 changes: 9 additions & 4 deletions src/mcsrranked/types/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,20 @@ class MatchSeed(BaseModel):
"""Match seed information."""

id: str | None = Field(default=None, description="Seed identifier")
overworld: str | None = Field(default=None, description="Overworld seed")
bastion: str | None = Field(default=None, description="Bastion type")
end_towers: list[int] = Field(
default_factory=list, alias="endTowers", description="End tower positions"
overworld: str | None = Field(default=None, description="Overworld structure type")
nether: str | None = Field(default=None, description="Bastion type")
end_towers: list[int] | None = Field(
default=None, alias="endTowers", description="End tower positions"
)
variations: list[str] = Field(default_factory=list, description="Seed variations")

model_config = {"populate_by_name": True}

@property
def bastion(self) -> str | None:
"""Alias for nether field (bastion type)."""
return self.nether


class EloChange(BaseModel):
"""Elo change data for a player in a match."""
Expand Down
71 changes: 68 additions & 3 deletions src/mcsrranked/types/user.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from pydantic import BaseModel, Field
from typing import Any

from pydantic import BaseModel, Field, model_validator

from mcsrranked.types.shared import Achievement

Expand Down Expand Up @@ -48,6 +50,51 @@ class MatchTypeStats(BaseModel):
model_config = {"populate_by_name": True}


def _pivot_stats(data: dict[str, Any]) -> dict[str, Any]:
"""Transform API format {stat: {mode: val}} to {mode: {stat: val}}.

The API returns statistics grouped by stat type first (e.g., wins.ranked),
but we want them grouped by mode first (e.g., ranked.wins) for easier access.
"""
if not isinstance(data, dict):
return data

# If already in correct format (ranked/casual at top level with nested stats)
if "ranked" in data and isinstance(data.get("ranked"), dict):
ranked_val = data["ranked"]
# Check if it looks like MatchTypeStats (has wins/losses as direct int values)
if isinstance(ranked_val.get("wins"), int | None):
return data

# Pivot from {wins: {ranked: X, casual: Y}} to {ranked: {wins: X}, casual: {wins: Y}}
ranked: dict[str, Any] = {}
casual: dict[str, Any] = {}

field_mapping = {
# API key -> model field name
"wins": "wins",
"loses": "losses", # Note: API uses 'loses' not 'losses'
"draws": "draws",
"forfeits": "forfeits",
"completions": "completions",
"playtime": "playtime",
"bestTime": "best_time",
"bestTimeId": "best_time_id",
"playedMatches": "played_matches",
"currentWinStreak": "current_winstreak",
"highestWinStreak": "highest_winstreak",
}

for api_key, model_key in field_mapping.items():
if api_key in data and isinstance(data[api_key], dict):
if data[api_key].get("ranked") is not None:
ranked[model_key] = data[api_key]["ranked"]
if data[api_key].get("casual") is not None:
casual[model_key] = data[api_key]["casual"]

return {"ranked": ranked, "casual": casual}


class SeasonStats(BaseModel):
"""Season statistics container."""

Expand All @@ -60,6 +107,12 @@ class SeasonStats(BaseModel):

model_config = {"populate_by_name": True}

@model_validator(mode="before")
@classmethod
def pivot_stats(cls, data: dict[str, Any]) -> dict[str, Any]:
"""Transform API stat-first format to mode-first format."""
return _pivot_stats(data)


class TotalStats(BaseModel):
"""All-time statistics container."""
Expand All @@ -73,6 +126,12 @@ class TotalStats(BaseModel):

model_config = {"populate_by_name": True}

@model_validator(mode="before")
@classmethod
def pivot_stats(cls, data: dict[str, Any]) -> dict[str, Any]:
"""Transform API stat-first format to mode-first format."""
return _pivot_stats(data)


class UserStatistics(BaseModel):
"""User statistics for season and total."""
Expand Down Expand Up @@ -235,8 +294,14 @@ class SeasonResultEntry(BaseModel):
"""Season result entry for user seasons endpoint."""

last: LastSeasonState = Field(description="Final season state")
highest: int = Field(description="Highest elo rating of season")
lowest: int = Field(description="Lowest elo rating of season")
highest: int | float | None = Field(
default=None,
description="Highest elo rating of season. None if no ranked matches.",
)
lowest: int | float | None = Field(
default=None,
description="Lowest elo rating of season. None if no ranked matches.",
)
phases: list[PhaseResult] = Field(
default_factory=list, description="Phase results for the season"
)
Expand Down
112 changes: 111 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
"""Pytest configuration and fixtures."""

import json
from collections.abc import Generator
from pathlib import Path
from typing import Any

import pytest
import respx

from mcsrranked import MCSRRanked

FIXTURES_DIR = Path(__file__).parent / "fixtures"


def load_fixture(name: str) -> dict[str, Any] | list[Any]:
"""Load a JSON fixture file."""
with open(FIXTURES_DIR / name) as f:
data: dict[str, Any] | list[Any] = json.load(f)
return data


@pytest.fixture
def client() -> MCSRRanked:
Expand All @@ -13,7 +27,103 @@ def client() -> MCSRRanked:


@pytest.fixture
def mock_api() -> respx.MockRouter:
def mock_api() -> Generator[respx.MockRouter, None, None]:
"""Create a mock API router."""
with respx.mock(base_url="https://api.mcsrranked.com") as respx_mock:
yield respx_mock


@pytest.fixture
def user_fixture() -> dict[str, Any]:
"""Load user.json fixture (Feinberg's profile)."""
result = load_fixture("user.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def user_matches_fixture() -> list[Any]:
"""Load user_matches.json fixture (Feinberg's recent matches)."""
result = load_fixture("user_matches.json")
assert isinstance(result, list)
return result


@pytest.fixture
def user_seasons_fixture() -> dict[str, Any]:
"""Load user_seasons.json fixture (Feinberg's season history)."""
result = load_fixture("user_seasons.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def versus_fixture() -> dict[str, Any]:
"""Load versus.json fixture (Feinberg vs Couriway stats)."""
result = load_fixture("versus.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def versus_matches_fixture() -> list[Any]:
"""Load versus_matches.json fixture (Feinberg vs Couriway matches)."""
result = load_fixture("versus_matches.json")
assert isinstance(result, list)
return result


@pytest.fixture
def matches_fixture() -> list[Any]:
"""Load matches.json fixture (recent ranked matches)."""
result = load_fixture("matches.json")
assert isinstance(result, list)
return result


@pytest.fixture
def match_detail_fixture() -> dict[str, Any]:
"""Load match_detail.json fixture (single match details)."""
result = load_fixture("match_detail.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def leaderboard_fixture() -> dict[str, Any]:
"""Load leaderboard.json fixture (elo leaderboard)."""
result = load_fixture("leaderboard.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def phase_leaderboard_fixture() -> dict[str, Any]:
"""Load phase_leaderboard.json fixture."""
result = load_fixture("phase_leaderboard.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def record_leaderboard_fixture() -> list[Any]:
"""Load record_leaderboard.json fixture."""
result = load_fixture("record_leaderboard.json")
assert isinstance(result, list)
return result


@pytest.fixture
def live_fixture() -> dict[str, Any]:
"""Load live.json fixture (live matches)."""
result = load_fixture("live.json")
assert isinstance(result, dict)
return result


@pytest.fixture
def weekly_race_fixture() -> dict[str, Any]:
"""Load weekly_race.json fixture."""
result = load_fixture("weekly_race.json")
assert isinstance(result, dict)
return result
Loading