Skip to content

Commit

Permalink
Migrate the Library to Pydantic V2 and Drop Support for Python 3.8 (#225
Browse files Browse the repository at this point in the history
)

* Remove mi18n stuff

* Fix typing error in ERRORS

* Reformat code

* Remove timezone stuff

* Fix comparing timezone aware dt to timezone naive dt

* Attempt to fix type check error

* Fix timezone issue in ClaimedDailyReward

* Do mass find and replace

* Use DateTimeField

* Presumably fix CI

* Fix lint error

* Fix validation error on TeapotReplica

* Ignore unserializable objects when json dumping in cache

* Fix validation errors in Notes

* Drop support for Python 3.8

* PyUpgrade safe fix

* PyUpgrade unsafe fixes

* Revert "PyUpgrade unsafe fixes"

This reverts commit 39597d6.

* Only apply typing.Dict and typing.List fixes

* Remove unused import

* Remove use of pydantic v1 validator

---------

Co-authored-by: ashlen <[email protected]>
  • Loading branch information
seriaati and thesadru authored Sep 22, 2024
1 parent cfa263c commit a29f909
Show file tree
Hide file tree
Showing 72 changed files with 480 additions and 1,058 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
if: github.event_name == 'push'
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10"]
python-version: ["3.9", "3.10", "3.11"]

steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ good references for how projects should be type-hinted to be type-complete.

- This project deviates from the common convention of importing types from the typing module and instead
imports the typing module itself to use generics and types in it like `typing.Union` and `typing.Optional`.
- Since this project supports python 3.8+, the `typing` module takes priority over `collections.abc`.
- Since this project supports python 3.9+, the `typing` module takes priority over `collections.abc`.
- All exported symbols should have docstrings.

---
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Key features:
## Requirements

- Python 3.8+
- Python 3.9+
- aiohttp
- Pydantic

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pip install git+https://github.com/thesadru/genshin.py

### Requirements:

- Python 3.8+
- Python 3.9+
- aiohttp
- Pydantic

Expand Down
8 changes: 4 additions & 4 deletions genshin-dev/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
import setuptools


def parse_requirements_file(path: pathlib.Path) -> typing.List[str]:
def parse_requirements_file(path: pathlib.Path) -> list[str]:
"""Parse a requirements file into a list of requirements."""
with open(path) as fp:
raw_dependencies = fp.readlines()

dependencies: typing.List[str] = []
dependencies: list[str] = []
for dependency in raw_dependencies:
comment_index = dependency.find("#")
if comment_index == 0:
Expand All @@ -30,8 +30,8 @@ def parse_requirements_file(path: pathlib.Path) -> typing.List[str]:

normal_requirements = parse_requirements_file(dev_directory / ".." / "requirements.txt")

all_extras: typing.Set[str] = set()
extras: typing.Dict[str, typing.Sequence[str]] = {}
all_extras: set[str] = set()
extras: dict[str, typing.Sequence[str]] = {}

for path in dev_directory.glob("*-requirements.txt"):
name = path.name.split("-")[0]
Expand Down
16 changes: 6 additions & 10 deletions genshin/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,7 @@ def client_command(func: typing.Callable[..., typing.Awaitable[typing.Any]]) ->
@functools.wraps(func)
@asynchronous
async def command(
cookies: typing.Optional[str] = None,
lang: str = "en-us",
debug: bool = False,
**kwargs: typing.Any,
cookies: typing.Optional[str] = None, lang: str = "en-us", debug: bool = False, **kwargs: typing.Any
) -> typing.Any:
client = genshin.Client(cookies, lang=lang, debug=debug)
if cookies is None:
Expand Down Expand Up @@ -85,10 +82,10 @@ async def honkai_stats(client: genshin.Client, uid: int) -> None:
data = await client.get_honkai_user(uid)

click.secho("Stats:", fg="yellow")
for k, v in data.stats.as_dict(lang=client.lang).items():
for k, v in data.stats.model_dump().items():
if isinstance(v, dict):
click.echo(f"{k}:")
for nested_k, nested_v in typing.cast("typing.Dict[str, object]", v).items():
for nested_k, nested_v in typing.cast("dict[str, object]", v).items():
click.echo(f" {nested_k}: {click.style(str(nested_v), bold=True)}")
else:
click.echo(f"{k}: {click.style(str(v), bold=True)}")
Expand All @@ -105,7 +102,7 @@ async def genshin_stats(client: genshin.Client, uid: int) -> None:
data = await client.get_partial_genshin_user(uid)

click.secho("Stats:", fg="yellow")
for k, v in data.stats.as_dict(lang=client.lang).items():
for k, v in data.stats.model_dump().items():
value = click.style(str(v), bold=True)
click.echo(f"{k}: {value}")

Expand Down Expand Up @@ -178,8 +175,7 @@ async def genshin_notes(client: genshin.Client, uid: typing.Optional[int]) -> No
click.echo(f"{click.style('Resin:', bold=True)} {data.current_resin}/{data.max_resin}")
click.echo(f"{click.style('Realm currency:', bold=True)} {data.current_realm_currency}/{data.max_realm_currency}")
click.echo(
f"{click.style('Commissions:', bold=True)} " f"{data.completed_commissions}/{data.max_commissions}",
nl=False,
f"{click.style('Commissions:', bold=True)} " f"{data.completed_commissions}/{data.max_commissions}", nl=False
)
if data.completed_commissions == data.max_commissions and not data.claimed_commission_reward:
click.echo(f" | [{click.style('X', fg='red')}] Haven't claimed rewards")
Expand Down Expand Up @@ -339,7 +335,7 @@ async def login(account: str, password: str, port: int) -> None:
"""Login with a password."""
client = genshin.Client()
result = await client.os_login_with_password(account, password, port=port)
cookies = await genshin.complete_cookies(result.dict())
cookies = await genshin.complete_cookies(result.model_dump())

base: http.cookies.BaseCookie[str] = http.cookies.BaseCookie(cookies)
click.echo(f"Your cookies are: {click.style(base.output(header='', sep=';'), bold=True)}")
Expand Down
6 changes: 3 additions & 3 deletions genshin/client/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import aiosqlite


__all__ = ["BaseCache", "Cache", "RedisCache", "StaticCache", "SQLiteCache"]
__all__ = ["BaseCache", "Cache", "RedisCache", "SQLiteCache", "StaticCache"]

MINUTE = 60
HOUR = MINUTE * 60
Expand All @@ -25,7 +25,7 @@

def _separate(values: typing.Iterable[typing.Any], sep: str = ":") -> str:
"""Separate a sequence by a separator into a single string."""
parts: typing.List[str] = []
parts: list[str] = []
for value in values:
if value is None:
parts.append("null")
Expand Down Expand Up @@ -83,7 +83,7 @@ async def set_static(self, key: typing.Any, value: typing.Any) -> None:
class Cache(BaseCache):
"""Standard implementation of the cache."""

cache: typing.Dict[typing.Any, typing.Tuple[float, typing.Any]]
cache: dict[typing.Any, tuple[float, typing.Any]]
maxsize: int
ttl: float
static_ttl: float
Expand Down
9 changes: 5 additions & 4 deletions genshin/client/components/auth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ async def verify_mmt(self, mmt_result: MMTResult) -> None:
**auth_utility.CREATE_MMT_HEADERS[self.region],
}

body = mmt_result.dict()
body = mmt_result.model_dump()
body["app_key"] = constants.GEETEST_RECORD_KEYS[self.default_game]

assert isinstance(self.cookie_manager, managers.CookieManager)
Expand Down Expand Up @@ -385,9 +385,10 @@ async def generate_fp(
device_id_key: str(uuid.uuid4()).lower(),
}

async with aiohttp.ClientSession() as session, session.post(
routes.GET_FP_URL.get_url(self.region), json=payload
) as r:
async with (
aiohttp.ClientSession() as session,
session.post(routes.GET_FP_URL.get_url(self.region), json=payload) as r,
):
data = await r.json()

if data["data"]["code"] != 200:
Expand Down
4 changes: 2 additions & 2 deletions genshin/client/components/auth/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

__all__ = ["PAGES", "enter_code", "launch_webapp", "solve_geetest"]

PAGES: typing.Final[typing.Dict[typing.Literal["captcha", "enter-code"], str]] = {
PAGES: typing.Final[dict[typing.Literal["captcha", "enter-code"], str]] = {
"captcha": """
<!DOCTYPE html>
<head>
Expand Down Expand Up @@ -159,7 +159,7 @@ async def gt(request: web.Request) -> web.StreamResponse:

@routes.get("/mmt")
async def mmt_endpoint(request: web.Request) -> web.Response:
return web.json_response(mmt.dict() if mmt else {})
return web.json_response(mmt.model_dump() if mmt else {})

@routes.post("/send-data")
async def send_data_endpoint(request: web.Request) -> web.Response:
Expand Down
2 changes: 1 addition & 1 deletion genshin/client/components/auth/subclients/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ async def _create_qrcode(self) -> QRCodeCreationResult:
url=data["data"]["url"],
)

async def _check_qrcode(self, ticket: str) -> typing.Tuple[QRCodeStatus, SimpleCookie]:
async def _check_qrcode(self, ticket: str) -> tuple[QRCodeStatus, SimpleCookie]:
"""Check the status of a QR code login."""
payload = {"ticket": ticket}

Expand Down
71 changes: 10 additions & 61 deletions genshin/client/components/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Base ABC Client."""

import abc
import asyncio
import base64
import functools
import json
Expand All @@ -20,7 +19,6 @@
from genshin.client import routes
from genshin.client.manager import managers
from genshin.models import hoyolab as hoyolab_models
from genshin.models import model as base_model
from genshin.utility import concurrency, deprecation, ds

__all__ = ["BaseClient"]
Expand Down Expand Up @@ -64,10 +62,10 @@ class BaseClient(abc.ABC):
_region: types.Region
_default_game: typing.Optional[types.Game]

uids: typing.Dict[types.Game, int]
authkeys: typing.Dict[types.Game, str]
uids: dict[types.Game, int]
authkeys: dict[types.Game, str]
_hoyolab_id: typing.Optional[int]
_accounts: typing.Dict[types.Game, hoyolab_models.GenshinAccount]
_accounts: dict[types.Game, hoyolab_models.GenshinAccount]
custom_headers: multidict.CIMultiDict[str]

def __init__(
Expand Down Expand Up @@ -289,22 +287,13 @@ def set_authkey(self, authkey: typing.Optional[str] = None, *, game: typing.Opti
self.authkeys[game] = authkey

def set_cache(
self,
maxsize: int = 1024,
*,
ttl: int = client_cache.HOUR,
static_ttl: int = client_cache.DAY,
self, maxsize: int = 1024, *, ttl: int = client_cache.HOUR, static_ttl: int = client_cache.DAY
) -> None:
"""Create and set a new cache."""
self.cache = client_cache.Cache(maxsize, ttl=ttl, static_ttl=static_ttl)

def set_redis_cache(
self,
url: str,
*,
ttl: int = client_cache.HOUR,
static_ttl: int = client_cache.DAY,
**redis_kwargs: typing.Any,
self, url: str, *, ttl: int = client_cache.HOUR, static_ttl: int = client_cache.DAY, **redis_kwargs: typing.Any
) -> None:
"""Create and set a new redis cache."""
import aioredis
Expand Down Expand Up @@ -384,12 +373,7 @@ async def request(
await self._request_hook(method, url, params=params, data=data, headers=headers, **kwargs)

response = await self.cookie_manager.request(
url,
method=method,
params=params,
json=data,
headers=headers,
**kwargs,
url, method=method, params=params, json=data, headers=headers, **kwargs
)

# cache
Expand Down Expand Up @@ -491,9 +475,7 @@ async def request_hoyolab(

@managers.no_multi
async def get_game_accounts(
self,
*,
lang: typing.Optional[str] = None,
self, *, lang: typing.Optional[str] = None
) -> typing.Sequence[hoyolab_models.GenshinAccount]:
"""Get the game accounts of the currently logged-in user."""
if self.hoyolab_id is None:
Expand All @@ -508,9 +490,7 @@ async def get_game_accounts(

@deprecation.deprecated("get_game_accounts")
async def genshin_accounts(
self,
*,
lang: typing.Optional[str] = None,
self, *, lang: typing.Optional[str] = None
) -> typing.Sequence[hoyolab_models.GenshinAccount]:
"""Get the genshin accounts of the currently logged-in user."""
accounts = await self.get_game_accounts(lang=lang)
Expand All @@ -520,7 +500,7 @@ async def _update_cached_uids(self) -> None:
"""Update cached fallback uids."""
mixed_accounts = await self.get_game_accounts()

game_accounts: typing.Dict[types.Game, typing.List[hoyolab_models.GenshinAccount]] = {}
game_accounts: dict[types.Game, list[hoyolab_models.GenshinAccount]] = {}
for account in mixed_accounts:
if not isinstance(account.game, types.Game): # pyright: ignore[reportUnnecessaryIsInstance]
continue
Expand Down Expand Up @@ -553,7 +533,7 @@ async def _update_cached_accounts(self) -> None:
"""Update cached fallback accounts."""
mixed_accounts = await self.get_game_accounts()

game_accounts: typing.Dict[types.Game, typing.List[hoyolab_models.GenshinAccount]] = {}
game_accounts: dict[types.Game, list[hoyolab_models.GenshinAccount]] = {}
for account in mixed_accounts:
if not isinstance(account.game, types.Game): # pyright: ignore[reportUnnecessaryIsInstance]
continue
Expand Down Expand Up @@ -592,37 +572,6 @@ def _get_hoyolab_id(self) -> int:

raise RuntimeError("No default hoyolab ID provided.")

async def _fetch_mi18n(self, key: str, lang: str, *, force: bool = False) -> None:
"""Update mi18n for a single url."""
if not force:
if key in base_model.APIModel._mi18n:
return

base_model.APIModel._mi18n[key] = {}

url = routes.MI18N[key]
cache_key = client_cache.cache_key("mi18n", mi18n=key, lang=lang)

data = await self.request_webstatic(url.format(lang=lang), cache=cache_key)
for k, v in data.items():
actual_key = str.lower(key + "/" + k)
base_model.APIModel._mi18n.setdefault(actual_key, {})[lang] = v

async def update_mi18n(self, langs: typing.Iterable[str] = constants.LANGS, *, force: bool = False) -> None:
"""Fetch mi18n for partially localized endpoints."""
if not force:
if base_model.APIModel._mi18n:
return

langs = tuple(langs)

coros: typing.List[typing.Awaitable[None]] = []
for key in routes.MI18N:
for lang in langs:
coros.append(self._fetch_mi18n(key, lang, force=force))

await asyncio.gather(*coros)


def region_specific(region: types.Region) -> typing.Callable[[AsyncCallableT], AsyncCallableT]:
"""Prevent function to be ran with unsupported regions."""
Expand Down
12 changes: 6 additions & 6 deletions genshin/client/components/calculator/calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class CalculatorState:
"""Stores character details if multiple objects require them."""

client: Client
cache: typing.Dict[str, typing.Any]
cache: dict[str, typing.Any]
lock: asyncio.Lock

character_id: typing.Optional[int] = None
Expand Down Expand Up @@ -150,7 +150,7 @@ async def __call__(self, state: CalculatorState) -> typing.Mapping[str, typing.A


class ArtifactResolver(CalculatorResolver[typing.Sequence[typing.Mapping[str, typing.Any]]]):
data: typing.List[typing.Mapping[str, typing.Any]]
data: list[typing.Mapping[str, typing.Any]]

def __init__(self) -> None:
self.data = []
Expand Down Expand Up @@ -208,7 +208,7 @@ async def __call__(self, state: CalculatorState) -> typing.Sequence[typing.Mappi


class TalentResolver(CalculatorResolver[typing.Sequence[typing.Mapping[str, typing.Any]]]):
data: typing.List[typing.Mapping[str, typing.Any]]
data: list[typing.Mapping[str, typing.Any]]

def __init__(self) -> None:
self.data = []
Expand Down Expand Up @@ -378,7 +378,7 @@ def with_current_talents(

async def build(self) -> typing.Mapping[str, typing.Any]:
"""Build the calculator object."""
data: typing.Dict[str, typing.Any] = {}
data: dict[str, typing.Any] = {}

if self.character:
data.update(await self.character(self._state))
Expand Down Expand Up @@ -408,7 +408,7 @@ class FurnishingCalculator:
client: Client
lang: typing.Optional[str]

furnishings: typing.Dict[int, int]
furnishings: dict[int, int]
replica_code: typing.Optional[int] = None
replica_region: typing.Optional[str] = None

Expand All @@ -434,7 +434,7 @@ def with_replica(self, code: int, *, region: typing.Optional[str] = None) -> Fur

async def build(self) -> typing.Mapping[str, typing.Any]:
"""Build the calculator object."""
data: typing.Dict[str, typing.Any] = {}
data: dict[str, typing.Any] = {}

if self.replica_code:
furnishings = await self.client.get_teapot_replica_blueprint(self.replica_code, region=self.replica_region)
Expand Down
Loading

0 comments on commit a29f909

Please sign in to comment.