From 0093fdf5eb05cdffd5cdb68b89427f625bc27a11 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 5 Sep 2024 13:44:56 +0100 Subject: [PATCH 01/42] [lichess] Start working on the Lichess integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It will be a long run 😅, but hey that's the very first stone now laid. With this 1st commit we add an ugly-but-functional Oauth2 flow that allows a user to get an API token from Lichess. --- .env.dist | 2 + pyproject.toml | 6 +- src/apps/lichess_bridge/__init__.py | 0 src/apps/lichess_bridge/authentication.py | 152 +++++++++++++++ .../lichess_bridge/components/__init__.py | 0 .../components/pages/__init__.py | 0 .../components/pages/lichess.py | 35 ++++ src/apps/lichess_bridge/cookie_helpers.py | 61 ++++++ src/apps/lichess_bridge/urls.py | 14 ++ src/apps/lichess_bridge/views.py | 61 ++++++ src/project/settings/_base.py | 14 +- src/project/urls.py | 5 +- uv.lock | 182 ++++++++++-------- 13 files changed, 451 insertions(+), 81 deletions(-) create mode 100644 src/apps/lichess_bridge/__init__.py create mode 100644 src/apps/lichess_bridge/authentication.py create mode 100644 src/apps/lichess_bridge/components/__init__.py create mode 100644 src/apps/lichess_bridge/components/pages/__init__.py create mode 100644 src/apps/lichess_bridge/components/pages/lichess.py create mode 100644 src/apps/lichess_bridge/cookie_helpers.py create mode 100644 src/apps/lichess_bridge/urls.py create mode 100644 src/apps/lichess_bridge/views.py diff --git a/.env.dist b/.env.dist index f368de6..d441c3a 100644 --- a/.env.dist +++ b/.env.dist @@ -1,3 +1,5 @@ # This file will never be used in production, so it's ok to commit this secret key :-) SECRET_KEY=not-a-security-issue DATABASE_URL=sqlite:///db.sqlite3 + +LICHESS_CLIENT_ID=zakuchess-local-dev diff --git a/pyproject.toml b/pyproject.toml index 86e918d..9539331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,8 +11,8 @@ readme = "README.md" requires-python = ">=3.11" dependencies= [ + # Django doesn't follow SemVer, so we need to specify the minor version: "Django==5.1.*", - # Django doesn't follow SemVer, so we need to specify the minor version "gunicorn==22.*", "django-alive==1.*", "chess==1.*", @@ -22,9 +22,11 @@ dependencies= [ "requests==2.*", "django-axes[ipware]==6.*", "whitenoise==6.*", - "django-import-export==3.*", + "django-import-export==4.*", "msgspec==0.18.*", "zakuchess", + "authlib==1.*", + "berserk>=0.13.2", ] diff --git a/src/apps/lichess_bridge/__init__.py b/src/apps/lichess_bridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/authentication.py b/src/apps/lichess_bridge/authentication.py new file mode 100644 index 0000000..a8e611a --- /dev/null +++ b/src/apps/lichess_bridge/authentication.py @@ -0,0 +1,152 @@ +# Packages such as django-allauth or AuthLib do provide turnkey Django integrations +# for OAuth2 - or even with Lichess specifically, for the former. +# However, in my case I don't want to use Django's "auth" machinery to manage Lichess +# users: all I want is to store in an HTTP-only cookie that they have attached +# a Lichess account, alongside with the token we'll need to communicate with Lichess. +# Hence, the following low level code, written after the Flask example given by Lichess: +# https://github.com/lakinwecker/lichess-oauth-flask/blob/master/app.py +# Authlib "vanilla Python" usage: +# https://docs.authlib.org/en/latest/client/oauth2.html + +import functools +from typing import TYPE_CHECKING, Literal + +import msgspec +from authlib.common.security import generate_token +from authlib.integrations.requests_client import OAuth2Session +from django.conf import settings +from django.urls import reverse + +if TYPE_CHECKING: + from typing import Self + +LICHESS_OAUTH2_SCOPES = ("board:play",) + + +class LichessTokenRetrievalProcessContext( + msgspec.Struct, + kw_only=True, # type: ignore[call-arg] +): + """ + Short-lived data required to complete the retrieval of an API token + from Lichess' OAuth2 process. + """ + + csrf_state: str + code_verifier: str + zakuchess_redirect_url: str # an absolute HTTP/HTTPS URL + + def to_cookie_content(self) -> str: + cookie_content = { + # We don't encode the redirect URL into the cookie, so let's customise + # what we need by encoding a dict, rather than "self" + "csrf": self.csrf_state, + "verif": self.code_verifier, + } + return msgspec.json.encode(cookie_content).decode() + + @classmethod + def from_cookie_content( + cls, + cookie_content: str, + *, + zakuchess_hostname: str, + zakuchess_protocol: str = "https", + ) -> "Self": + cookie_content_dict = msgspec.json.decode(cookie_content) + redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri( + zakuchess_protocol, + zakuchess_hostname, + ) + + return cls( + csrf_state=cookie_content_dict["csrf"], + code_verifier=cookie_content_dict["verif"], + zakuchess_redirect_url=redirect_uri, + ) + + @classmethod + def create_afresh( + cls, + *, + zakuchess_hostname: str, + zakuchess_protocol: str = "https", + ) -> "Self": + """ + Returns a context with randomly generated "CSRF state" and "code verifier". + """ + redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri( + zakuchess_protocol, zakuchess_hostname + ) + + csrf_state = generate_token() + code_verifier = generate_token(48) + + return cls( + csrf_state=csrf_state, + code_verifier=code_verifier, + zakuchess_redirect_url=redirect_uri, + ) + + +class LichessToken(msgspec.Struct): + token_type: Literal["Bearer"] + access_token: str + expires_in: int # number of seconds + expires_at: int # a Unix timestamp + + +def get_lichess_token_retrieval_via_oauth2_process_starting_url( + *, + context: LichessTokenRetrievalProcessContext, +) -> str: + lichess_authorization_endpoint = f"{settings.LICHESS_HOST}/oauth" + + client = _get_lichess_client() + uri, state = client.create_authorization_url( + lichess_authorization_endpoint, + response_type="code", + state=context.csrf_state, + redirect_uri=context.zakuchess_redirect_url, + code_verifier=context.code_verifier, + ) + assert state == context.csrf_state + + return uri + + +def extract_lichess_token_from_oauth2_callback_url( + *, + authorization_callback_response_url: str, + context: LichessTokenRetrievalProcessContext, +) -> LichessToken: + lichess_token_endpoint = f"{settings.LICHESS_HOST}/api/token" + + client = _get_lichess_client() + token_as_dict = client.fetch_token( + lichess_token_endpoint, + authorization_response=authorization_callback_response_url, + redirect_uri=context.zakuchess_redirect_url, + code_verifier=context.code_verifier, + ) + + return LichessToken( + **token_as_dict, + ) + + +@functools.lru_cache +def _get_lichess_oauth2_zakuchess_redirect_uri( + zakuchess_protocol: str, zakuchess_hostname: str +) -> str: + return f"{zakuchess_protocol}://{zakuchess_hostname}" + reverse( + "lichess_bridge:oauth2_token_callback" + ) + + +def _get_lichess_client() -> OAuth2Session: + return OAuth2Session( + client_id=settings.LICHESS_CLIENT_ID, + code_challenge_method="S256", + scope=" ".join(LICHESS_OAUTH2_SCOPES), + ) diff --git a/src/apps/lichess_bridge/components/__init__.py b/src/apps/lichess_bridge/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/pages/__init__.py b/src/apps/lichess_bridge/components/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/pages/lichess.py b/src/apps/lichess_bridge/components/pages/lichess.py new file mode 100644 index 0000000..2553336 --- /dev/null +++ b/src/apps/lichess_bridge/components/pages/lichess.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +from dominate.tags import section +from dominate.util import raw + +from apps.lichess_bridge.authentication import ( + get_lichess_token_retrieval_via_oauth2_process_starting_url, +) +from apps.webui.components.layout import page + +if TYPE_CHECKING: + from django.http import HttpRequest + + from apps.lichess_bridge.authentication import ( + LichessTokenRetrievalProcessContext, + ) + + +def lichess_no_account_linked_page( + *, + request: "HttpRequest", + lichess_oauth2_process_context: "LichessTokenRetrievalProcessContext", +) -> str: + target_url = get_lichess_token_retrieval_via_oauth2_process_starting_url( + context=lichess_oauth2_process_context + ) + + return page( + section( + raw(f"""Click here: {target_url}"""), + cls="text-slate-50", + ), + request=request, + title="Lichess - no account linked", + ) diff --git a/src/apps/lichess_bridge/cookie_helpers.py b/src/apps/lichess_bridge/cookie_helpers.py new file mode 100644 index 0000000..b4c80fa --- /dev/null +++ b/src/apps/lichess_bridge/cookie_helpers.py @@ -0,0 +1,61 @@ +import logging +from typing import TYPE_CHECKING + +from msgspec import MsgspecError + +from .authentication import LichessTokenRetrievalProcessContext + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + +_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME = "lichess.oauth2.ctx" +# One day should be more than enough to let the user grant their authorisation: +_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_MAX_AGE = 3600 * 24 + + +_logger = logging.getLogger(__name__) + + +def store_oauth2_token_retrieval_context_in_response_cookie( + *, context: LichessTokenRetrievalProcessContext, response: "HttpResponse" +) -> None: + """ + Store OAuth2 token retrieval context into a response cookie. + """ + + response.set_cookie( + _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME, + context.to_cookie_content(), + max_age=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_MAX_AGE, + httponly=True, + ) + + +def get_oauth2_token_retrieval_context_from_request( + request: "HttpRequest", +) -> LichessTokenRetrievalProcessContext | None: + """ + Returns a context created from the "CSRF state" and "code verifier" found in the request's cookie. + """ + cookie_content: str | None = request.COOKIES.get( + _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME + ) + if not cookie_content: + return None + + try: + context = LichessTokenRetrievalProcessContext.from_cookie_content( + cookie_content, + zakuchess_hostname=request.get_host(), + zakuchess_protocol=request.scheme, + ) + return context + except MsgspecError: + _logger.exception("Could not decode cookie content.") + return None + + +def delete_oauth2_token_retrieval_context_from_cookies( + response: "HttpResponse", +) -> None: + response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME) diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py new file mode 100644 index 0000000..85d8442 --- /dev/null +++ b/src/apps/lichess_bridge/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +app_name = "lichess_bridge" + +urlpatterns = [ + path("", views.lichess_home, name="homepage"), + path( + "webhook/oauth2/token-callback/", + views.lichess_webhook_oauth2_token_callback, + name="oauth2_token_callback", + ), +] diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py new file mode 100644 index 0000000..c91551a --- /dev/null +++ b/src/apps/lichess_bridge/views.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING + +from django.http import HttpResponse +from django.shortcuts import redirect +from django.views.decorators.http import require_GET + +from .authentication import ( + LichessTokenRetrievalProcessContext, + extract_lichess_token_from_oauth2_callback_url, +) +from .components.pages.lichess import lichess_no_account_linked_page +from .cookie_helpers import ( + delete_oauth2_token_retrieval_context_from_cookies, + get_oauth2_token_retrieval_context_from_request, + store_oauth2_token_retrieval_context_in_response_cookie, +) + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def lichess_home(request: "HttpRequest") -> HttpResponse: + lichess_oauth2_process_context = LichessTokenRetrievalProcessContext.create_afresh( + zakuchess_hostname=request.get_host(), + zakuchess_protocol=request.scheme, + ) + + response = HttpResponse( + lichess_no_account_linked_page( + request=request, + lichess_oauth2_process_context=lichess_oauth2_process_context, + ) + ) + # We will need to re-use some of this context's data in the webhook below: + # --> let's store that in an HTTP-only cookie + store_oauth2_token_retrieval_context_in_response_cookie( + context=lichess_oauth2_process_context, response=response + ) + + return response + + +@require_GET +def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpResponse: + # Retrieve a context from the HTTP-only cookie we created above: + lichess_oauth2_process_context = get_oauth2_token_retrieval_context_from_request( + request + ) + if lichess_oauth2_process_context is None: + # TODO: Do something with that error + return redirect("lichess_bridge:homepage") + + token = extract_lichess_token_from_oauth2_callback_url( + authorization_callback_response_url=request.get_full_path(), + context=lichess_oauth2_process_context, + ) + + response = HttpResponse(f"{token=}") + delete_oauth2_token_retrieval_context_from_cookies(response) + + return response diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index 3ff296c..6a1e6a9 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -48,6 +48,7 @@ "apps.authentication", "apps.chess", "apps.daily_challenge", + "apps.lichess_bridge", "apps.webui", ] ) @@ -191,7 +192,18 @@ # Our custom settings: ZAKUCHESS_VERSION = env.get("ZAKUCHESS_VERSION", "dev") -JS_CHESS_ENGINE = env.get("JS_CHESS_ENGINE", "stockfish") MASTODON_PAGE = env.get("MASTODON_PAGE") CANONICAL_URL = env.get("CANONICAL_URL", "https://zakuchess.com/") + DEBUG_LAYOUT = env.get("DEBUG_LAYOUT", "") == "1" + +# Daily challenge app: +JS_CHESS_ENGINE = env.get("JS_CHESS_ENGINE", "stockfish") + +# Lichess bridge app: +# > Lichess supports unregistered and public clients +# > (no client authentication, choose any unique client id). +# So it's not a kind of API secret we would have created on Lichess' side, but just an +# arbitrary identifier. +LICHESS_CLIENT_ID = env.get("LICHESS_CLIENT_ID", "") +LICHESS_HOST = env.get("LICHESS_HOST", "https://lichess.org") diff --git a/src/project/urls.py b/src/project/urls.py index eb1742f..ff0d002 100644 --- a/src/project/urls.py +++ b/src/project/urls.py @@ -1,7 +1,7 @@ """project URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.0/topics/http/urls/ + https://docs.djangoproject.com/en/5.1/topics/http/urls/ """ from django.conf import settings @@ -14,12 +14,13 @@ urlpatterns = [ path("", include("apps.daily_challenge.urls")), + path("lichess/", include("apps.lichess_bridge.urls")), path("-/", include("django_alive.urls")), path("admin/", admin.site.urls), ] if settings.DEBUG: - # @link https://docs.djangoproject.com/en/5.0/howto/static-files/ + # @link https://docs.djangoproject.com/en/5.1/howto/static-files/ from django.conf.urls.static import static diff --git a/uv.lock b/uv.lock index 695f977..a659673 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764 }, ] +[[package]] +name = "authlib" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/75/47dbab150ef6f9298e227a40c93c7fed5f3ffb67c9fb62cd49f66285e46e/authlib-1.3.2.tar.gz", hash = "sha256:4b16130117f9eb82aa6eec97f6dd4673c3f960ac0283ccdae2897ee4bc030ba2", size = 147313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/4c/9aa0416a403d5cc80292cb030bcd2c918cce2755e314d8c1aa18656e1e12/Authlib-1.3.2-py2.py3-none-any.whl", hash = "sha256:ede026a95e9f5cdc2d4364a52103f5405e75aa156357e831ef2bfd0bc5094dfc", size = 225111 }, +] + +[[package]] +name = "berserk" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "ndjson" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/83/c35e86fcd96cef66dac6f19dd2950e977828ac461afc4f179a41bf951bee/berserk-0.13.2.tar.gz", hash = "sha256:96c3ff3a10407842019e5e6bf3233080030419e4eba333bbd4234a86b4eff86f", size = 55539 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/3e/8b2c89d97212e5de0b433961bf76e4126c18ff3cde15e0e099993123cc5d/berserk-0.13.2-py3-none-any.whl", hash = "sha256:0f7fc40f152370924cb05a77c3f1c357a91e8ff0db60d23c14f0f16216b632a8", size = 74479 }, +] + [[package]] name = "blinker" version = "1.8.2" @@ -286,6 +314,35 @@ toml = [ { name = "tomli", marker = "python_full_version == '3.11'" }, ] +[[package]] +name = "cryptography" +version = "43.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ba/0664727028b37e249e73879348cc46d45c5c1a2a2e81e8166462953c5755/cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d", size = 686927 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/28/b92c98a04ba762f8cdeb54eba5c4c84e63cac037a7c5e70117d337b15ad6/cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d", size = 6223222 }, + { url = "https://files.pythonhosted.org/packages/33/13/1193774705783ba364121aa2a60132fa31a668b8ababd5edfa1662354ccd/cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062", size = 3794751 }, + { url = "https://files.pythonhosted.org/packages/5e/4b/39bb3c4c8cfb3e94e736b8d8859ce5c81536e91a1033b1d26770c4249000/cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962", size = 3981827 }, + { url = "https://files.pythonhosted.org/packages/ce/dc/1471d4d56608e1013237af334b8a4c35d53895694fbb73882d1c4fd3f55e/cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277", size = 3780034 }, + { url = "https://files.pythonhosted.org/packages/ad/43/7a9920135b0d5437cc2f8f529fa757431eb6a7736ddfadfdee1cc5890800/cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a", size = 3993407 }, + { url = "https://files.pythonhosted.org/packages/cc/42/9ab8467af6c0b76f3d9b8f01d1cf25b9c9f3f2151f4acfab888d21c55a72/cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042", size = 3886457 }, + { url = "https://files.pythonhosted.org/packages/a4/65/430509e31700286ec02868a2457d2111d03ccefc20349d24e58d171ae0a7/cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494", size = 4081499 }, + { url = "https://files.pythonhosted.org/packages/bb/18/a04b6467e6e09df8c73b91dcee8878f4a438a43a3603dc3cd6f8003b92d8/cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2", size = 2616504 }, + { url = "https://files.pythonhosted.org/packages/cc/73/0eacbdc437202edcbdc07f3576ed8fb8b0ab79d27bf2c5d822d758a72faa/cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d", size = 3067456 }, + { url = "https://files.pythonhosted.org/packages/8a/b6/bc54b371f02cffd35ff8dc6baba88304d7cf8e83632566b4b42e00383e03/cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d", size = 6225263 }, + { url = "https://files.pythonhosted.org/packages/00/0e/8217e348a1fa417ec4c78cd3cdf24154f5e76fd7597343a35bd403650dfd/cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806", size = 3794368 }, + { url = "https://files.pythonhosted.org/packages/3d/ed/38b6be7254d8f7251fde8054af597ee8afa14f911da67a9410a45f602fc3/cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85", size = 3981750 }, + { url = "https://files.pythonhosted.org/packages/64/f3/b7946c3887cf7436f002f4cbb1e6aec77b8d299b86be48eeadfefb937c4b/cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c", size = 3778925 }, + { url = "https://files.pythonhosted.org/packages/ac/7e/ebda4dd4ae098a0990753efbb4b50954f1d03003846b943ea85070782da7/cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1", size = 3993152 }, + { url = "https://files.pythonhosted.org/packages/43/f6/feebbd78a3e341e3913846a3bb2c29d0b09b1b3af1573c6baabc2533e147/cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa", size = 3886392 }, + { url = "https://files.pythonhosted.org/packages/bd/4c/ab0b9407d5247576290b4fd8abd06b7f51bd414f04eef0f2800675512d61/cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4", size = 4082606 }, + { url = "https://files.pythonhosted.org/packages/05/36/e532a671998d6fcfdb9122da16434347a58a6bae9465e527e450e0bc60a5/cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47", size = 2617948 }, + { url = "https://files.pythonhosted.org/packages/b3/c6/c09cee6968add5ff868525c3815e5dccc0e3c6e89eec58dc9135d3c40e88/cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb", size = 3070445 }, +] + [[package]] name = "decorator" version = "5.1.1" @@ -296,12 +353,15 @@ wheels = [ ] [[package]] -name = "defusedxml" -version = "0.7.1" +name = "deprecated" +version = "1.2.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3", size = 2974416 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, + { url = "https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", size = 9561 }, ] [[package]] @@ -405,16 +465,16 @@ wheels = [ [[package]] name = "django-import-export" -version = "3.3.9" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "diff-match-patch" }, { name = "django" }, - { name = "tablib", extra = ["html", "ods", "xls", "xlsx", "yaml"] }, + { name = "tablib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/dc/915a0df2b9006cfd6870e04967964eacdfed0db3d5edf61a72b0d199407a/django_import_export-3.3.9.tar.gz", hash = "sha256:16797965e93a8001fe812c61e3b71fb858c57c1bd16da195fe276d6de685348e", size = 64625 } +sdist = { url = "https://files.pythonhosted.org/packages/29/77/b23eaeb57802999d1b4bcebeb6afb11ab9666879fc43547df725fc516016/django_import_export-4.1.1.tar.gz", hash = "sha256:16ecc5a9f0df46bde6eb278a3e65ebda0ee1db55656f36440e9fb83f40ab85a3", size = 2350049 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/06/2b474ddf52d5e2e46ea821789de6a76bc50996f0a592fdd29af1891d20af/django_import_export-3.3.9-py3-none-any.whl", hash = "sha256:dd6cabc08ed6d1bd37a392e7fb542bd7d196b615c800168f5c69f0f55f49b103", size = 112693 }, + { url = "https://files.pythonhosted.org/packages/02/49/9101262deb3c0c832071da8087abfdf160b52bf3a7b92d79898c39d038c2/django_import_export-4.1.1-py3-none-any.whl", hash = "sha256:730ae2443a02b1ba27d8dba078a27ae9123adfcabb78161b4f130843607b3df9", size = 134031 }, ] [[package]] @@ -438,15 +498,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/71/273f51cd3be1d3279ce3fc63bb46bdb4a82bb9445e9684100618124f9920/dominate-2.7.0-py2.py3-none-any.whl", hash = "sha256:5fe4258614687c6d3de67b0bbd881ed435a93a19742ae187344055db17052402", size = 29372 }, ] -[[package]] -name = "et-xmlfile" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3d/5d/0413a31d184a20c763ad741cc7852a659bf15094c24840c5bdd1754765cd/et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c", size = 3218 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c2/3dd434b0108730014f1b96fd286040dc3bcb70066346f7e01ec2ac95865f/et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada", size = 4688 }, -] - [[package]] name = "executing" version = "2.1.0" @@ -758,12 +809,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/6b/3aa29e826ce02a3ca00b2e14f4955de0ef9e749badca04cac12c9eb562d4/locust-2.31.5-py3-none-any.whl", hash = "sha256:2904ff6307d54d3202c9ebd776f9170214f6dfbe4059504dad9e3ffaca03f600", size = 1204517 }, ] -[[package]] -name = "markuppy" -version = "1.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/ca/f43541b41bd17fc945cfae7ea44f1661dc21ea65ecc944a6fa138eead94c/MarkupPy-1.14.tar.gz", hash = "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f", size = 6815 } - [[package]] name = "markupsafe" version = "2.1.5" @@ -889,33 +934,21 @@ wheels = [ ] [[package]] -name = "nodeenv" -version = "1.9.1" +name = "ndjson" +version = "0.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/b4/d5/209b6ca94566f9c94c0ec41cee1681c0a3b92a306a84a9b0fcd662088dc3/ndjson-0.3.1.tar.gz", hash = "sha256:bf9746cb6bb1cb53d172cda7f154c07c786d665ff28341e4e689b796b229e5d6", size = 6448 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, -] - -[[package]] -name = "odfpy" -version = "1.4.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "defusedxml" }, + { url = "https://files.pythonhosted.org/packages/70/c9/04ba0056011ba96a58163ebfd666d8385300bd12da1afe661a5a147758d7/ndjson-0.3.1-py2.py3-none-any.whl", hash = "sha256:839c22275e6baa3040077b83c005ac24199b94973309a8a1809be962c753a410", size = 5305 }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/73/8ade73f6749177003f7ce3304f524774adda96e6aaab30ea79fd8fda7934/odfpy-1.4.1.tar.gz", hash = "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec", size = 717045 } [[package]] -name = "openpyxl" -version = "3.1.5" +name = "nodeenv" +version = "1.9.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "et-xmlfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] [[package]] @@ -1346,24 +1379,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/02/404b9a79578e1a3512bf3ae5e1fb0766859ccf3b55a83ab1e7ac4aeb7bed/tablib-3.5.0-py3-none-any.whl", hash = "sha256:9821caa9eca6062ff7299fa645e737aecff982e6b2b42046928a6413c8dabfd9", size = 45479 }, ] -[package.optional-dependencies] -html = [ - { name = "markuppy" }, -] -ods = [ - { name = "odfpy" }, -] -xls = [ - { name = "xlrd" }, - { name = "xlwt" }, -] -xlsx = [ - { name = "openpyxl" }, -] -yaml = [ - { name = "pyyaml" }, -] - [[package]] name = "tabulate" version = "0.9.0" @@ -1520,21 +1535,32 @@ wheels = [ ] [[package]] -name = "xlrd" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/b3/19a2540d21dea5f908304375bd43f5ed7a4c28a370dc9122c565423e6b44/xlrd-2.0.1.tar.gz", hash = "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88", size = 100259 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/0c/c2a72d51fe56e08a08acc85d13013558a2d793028ae7385448a6ccdfae64/xlrd-2.0.1-py2.py3-none-any.whl", hash = "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", size = 96531 }, -] - -[[package]] -name = "xlwt" -version = "1.3.0" +name = "wrapt" +version = "1.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/97/56a6f56ce44578a69343449aa5a0d98eefe04085d69da539f3034e2cd5c1/xlwt-1.3.0.tar.gz", hash = "sha256:c59912717a9b28f1a3c2a98fd60741014b06b043936dcecbc113eaaada156c88", size = 153929 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/48/def306413b25c3d01753603b1a222a011b8621aed27cd7f89cbc27e6b0f4/xlwt-1.3.0-py2.py3-none-any.whl", hash = "sha256:a082260524678ba48a297d922cc385f58278b8aa68741596a87de01a9c628b2e", size = 99981 }, +sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313 }, + { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890 }, + { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118 }, + { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746 }, + { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556 }, + { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712 }, + { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327 }, + { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523 }, + { url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614 }, + { url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316 }, + { url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322 }, + { url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055 }, + { url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291 }, + { url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896 }, + { url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738 }, + { url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568 }, + { url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653 }, + { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, ] [[package]] @@ -1542,6 +1568,8 @@ name = "zakuchess" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "authlib" }, + { name = "berserk" }, { name = "chess" }, { name = "dj-database-url" }, { name = "django" }, @@ -1580,6 +1608,8 @@ test = [ [package.metadata] requires-dist = [ + { name = "authlib", specifier = "==1.*" }, + { name = "berserk", specifier = ">=0.13.2" }, { name = "chess", specifier = "==1.*" }, { name = "dj-database-url", specifier = "==2.*" }, { name = "django", specifier = "==5.1.*" }, @@ -1587,7 +1617,7 @@ requires-dist = [ { name = "django-axes", extras = ["ipware"], specifier = "==6.*" }, { name = "django-extensions", marker = "extra == 'dev'", specifier = "==3.*" }, { name = "django-htmx", specifier = "==1.*" }, - { name = "django-import-export", specifier = "==3.*" }, + { name = "django-import-export", specifier = "==4.*" }, { name = "dominate", specifier = "==2.*" }, { name = "gunicorn", specifier = "==22.*" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.26.*" }, From e7de8889dc689475d46e18910ad6060eec0fcd3e Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 5 Sep 2024 13:45:59 +0100 Subject: [PATCH 02/42] [chore] After the switch to uv, fix some minor issues and improve the Makefile --- Makefile | 24 ++++++++++++++++-------- dev-start.zsh | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 2d4623e..6958c1b 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ PYTHON_BINS ?= ./.venv/bin PYTHON ?= ${PYTHON_BINS}/python DJANGO_SETTINGS_MODULE ?= project.settings.development SUB_MAKE = ${MAKE} --no-print-directory +UV ?= bin/uv .DEFAULT_GOAL := help @@ -10,10 +11,8 @@ help: @grep -P '^[.a-zA-Z/_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' .PHONY: install -install: bin/uv .venv ./node_modules ## Install the Python and frontend dependencies - bin/uv sync --all-extras - ${PYTHON_BINS}/pre-commit install - ${SUB_MAKE} .venv/bin/black +install: backend/install frontend/install ## Install the Python and frontend dependencies + .PHONY: dev dev: .env.local db.sqlite3 @@ -31,6 +30,13 @@ download_assets: download_assets_opts ?= download_assets: ${PYTHON_BINS}/python scripts/download_assets.py ${download_assets_opts} +.PHONY: backend/install +backend/install: uv_sync_opts ?= --all-extras --no-build +backend/install: bin/uv .venv ## Install the Python dependencies (via uv) and install pre-commit + ${UV} sync ${uv_sync_opts} + ${PYTHON_BINS}/pre-commit install + @${SUB_MAKE} .venv/bin/black + .PHONY: backend/watch backend/watch: address ?= localhost backend/watch: port ?= 8000 @@ -83,8 +89,12 @@ code-quality/mypy: ## Python's equivalent of TypeScript # Here starts the frontend stuff +.PHONY: frontend/install +frontend/install: ## Install the frontend dependencies (via npm) + npm install + .PHONY: frontend/watch -frontend/watch: ## Compile the CSS & JS assets of our various Django apps, in 'watch' mode +frontend/watch: ./node_modules ## Compile the CSS & JS assets of our various Django apps, in 'watch' mode @./node_modules/.bin/concurrently --names "img,css,js" --prefix-colors "yellow,green" \ "${SUB_MAKE} frontend/img" \ "${SUB_MAKE} frontend/css/watch" \ @@ -150,7 +160,7 @@ bin/uv: # Install `uv` and `uvx` locally in the "bin/" folder @echo "We'll use 'bin/uv' to manage Python dependencies." .venv: ## Initialises the Python virtual environment in a ".venv" folder, via uv - bin/uv venv + ${UV} venv .env.local: cp .env.dist .env.local @@ -179,8 +189,6 @@ django/manage: .venv .env.local ## Run a Django management command ./node_modules: frontend/install -frontend/install: - npm install # Here starts the "Lichess database" stuff diff --git a/dev-start.zsh b/dev-start.zsh index 982ff88..b8d75f5 100755 --- a/dev-start.zsh +++ b/dev-start.zsh @@ -16,7 +16,7 @@ export DJANGO_SETTINGS_MODULE=project.settings.development alias run_in_dotenv='dotenv -f .env.local run -- ' alias uv='bin/uv' -alias djm='run_in_dotenv python src/manage.py' +alias djm='run_in_dotenv python manage.py' alias test='DJANGO_SETTINGS_MODULE=project.settings.test run_in_dotenv pytest -x --reuse-db' alias test-no-reuse='DJANGO_SETTINGS_MODULE=project.settings.test run_in_dotenv pytest -x' From 0a204927f286110791b85b1b227bc5939963fb07 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 5 Sep 2024 16:37:35 +0100 Subject: [PATCH 03/42] [lichess] We can now log in and log out from Lichess in the UI Still super basic and ugly at the moment, but that's a good start :-) --- pyproject.toml | 3 + .../components/misc_ui/daily_challenge_bar.py | 8 +- .../components/misc_ui/help.py | 11 +-- .../components/misc_ui/status_bar.py | 5 +- .../components/misc_ui/user_prefs_modal.py | 4 +- src/apps/daily_challenge/cookie_helpers.py | 13 +-- src/apps/daily_challenge/view_helpers.py | 13 ++- src/apps/lichess_bridge/authentication.py | 4 +- src/apps/lichess_bridge/components/misc_ui.py | 26 ++++++ .../components/pages/lichess.py | 61 ++++++++++---- .../lichess_bridge/components/svg_icons.py | 15 ++++ src/apps/lichess_bridge/cookie_helpers.py | 80 ++++++++++++++++--- src/apps/lichess_bridge/lichess_api.py | 16 ++++ src/apps/lichess_bridge/models.py | 8 ++ src/apps/lichess_bridge/tests/__init__.py | 0 src/apps/lichess_bridge/tests/test_views.py | 39 +++++++++ src/apps/lichess_bridge/urls.py | 12 ++- src/apps/lichess_bridge/views.py | 73 ++++++++++++----- .../components}/common_styles.py | 0 src/apps/webui/components/forms_common.py | 13 +++ src/apps/webui/components/layout.py | 4 +- uv.lock | 14 ++++ 22 files changed, 348 insertions(+), 74 deletions(-) create mode 100644 src/apps/lichess_bridge/components/misc_ui.py create mode 100644 src/apps/lichess_bridge/components/svg_icons.py create mode 100644 src/apps/lichess_bridge/lichess_api.py create mode 100644 src/apps/lichess_bridge/models.py create mode 100644 src/apps/lichess_bridge/tests/__init__.py create mode 100644 src/apps/lichess_bridge/tests/test_views.py rename src/apps/{daily_challenge/components/misc_ui => webui/components}/common_styles.py (100%) create mode 100644 src/apps/webui/components/forms_common.py diff --git a/pyproject.toml b/pyproject.toml index 9539331..f24392f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ test = [ "pytest-django==4.*", "pytest-cov==4.*", "time-machine==2.*", + "pytest-blockage==0.2.*", ] load-testing = [ "locust==2.*", @@ -109,6 +110,7 @@ exclude = [ ] [[tool.mypy.overrides]] module = [ + "authlib.*", "django.*", "dominate.*", "import_export.*", @@ -125,6 +127,7 @@ testpaths = [ python_files = ["test_*.py"] addopts = "--reuse-db" DJANGO_SETTINGS_MODULE = "project.settings.test" +blockage = true # https://github.com/rob-b/pytest-blockage [tool.coverage.run] # @link https://coverage.readthedocs.io/en/latest/excluding.html diff --git a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py index cc24730..b3d502e 100644 --- a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py @@ -9,9 +9,9 @@ from dominate.util import raw from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM +from apps.webui.components import common_styles from ...models import PlayerGameOverState -from .common_styles import BUTTON_CANCEL_CLASSES, BUTTON_CLASSES, BUTTON_CONFIRM_CLASSES from .svg_icons import ( ICON_SVG_COG, ICON_SVG_LIGHT_BULB, @@ -163,14 +163,14 @@ def _confirmation_dialog( "Confirm", " ", ICON_SVG_CONFIRM, - cls=BUTTON_CONFIRM_CLASSES, + cls=common_styles.BUTTON_CONFIRM_CLASSES, **htmx_attributes_confirm, ), button( "Cancel", " ", ICON_SVG_CANCEL, - cls=BUTTON_CANCEL_CLASSES, + cls=common_styles.BUTTON_CANCEL_CLASSES, **htmx_attributes_cancel, ), cls="text-center", @@ -362,7 +362,7 @@ def _user_prefs_button(board_id: str) -> "dom_tag": def _button_classes(*, full_width: bool = True, disabled: bool = False) -> str: return " ".join( ( - BUTTON_CLASSES, + common_styles.BUTTON_CLASSES, ("w-full" if full_width else ""), (" opacity-50 cursor-not-allowed" if disabled else ""), ) diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index 119b488..bddb975 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -9,15 +9,12 @@ from apps.chess.components.chess_board import SQUARE_COLOR_TAILWIND_CLASSES from apps.chess.components.chess_helpers import chess_unit_symbol_class from apps.chess.consts import PIECE_TYPE_TO_NAME -from apps.daily_challenge.components.misc_ui.common_styles import ( - BUTTON_BASE_HOVER_TEXT_COLOR, - BUTTON_CLASSES, -) from apps.daily_challenge.components.misc_ui.svg_icons import ( ICON_SVG_COG, ICON_SVG_LIGHT_BULB, ICON_SVG_RESTART, ) +from apps.webui.components import common_styles if TYPE_CHECKING: from dominate.tags import dom_tag @@ -96,7 +93,7 @@ def help_content( span( "Retry", ICON_SVG_RESTART, - cls=f"{BUTTON_CLASSES.replace(BUTTON_BASE_HOVER_TEXT_COLOR, '')} !mx-0", + cls=f"{common_styles.BUTTON_CLASSES.replace(common_styles.BUTTON_BASE_HOVER_TEXT_COLOR, '')} !mx-0", ), " button.", cls=f"{spacing}", @@ -107,7 +104,7 @@ def help_content( span( "See solution", ICON_SVG_LIGHT_BULB, - cls=f"{BUTTON_CLASSES} !inline-block !mx-0", + cls=f"{common_styles.BUTTON_CLASSES} !inline-block !mx-0", ), " button.", cls=f"{spacing}", @@ -118,7 +115,7 @@ def help_content( span( "Options", ICON_SVG_COG, - cls=f"{BUTTON_CLASSES} !inline-block !mx-0", + cls=f"{common_styles.BUTTON_CLASSES} !inline-block !mx-0", ), " button.", cls=f"{spacing}", diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/misc_ui/status_bar.py index 8871962..f4f7252 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/status_bar.py @@ -15,8 +15,7 @@ help_content, unit_display_container, ) - -from .common_styles import BUTTON_CLASSES +from apps.webui.components import common_styles if TYPE_CHECKING: from dominate.tags import dom_tag @@ -47,7 +46,7 @@ def status_bar( div( button( "⇧ Scroll up to the board", - cls=BUTTON_CLASSES, + cls=common_styles.BUTTON_CLASSES, onclick="""window.scrollTo({ top: 0, behavior: "smooth" })""", ), cls="w-full flex justify-center", diff --git a/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py b/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py index 7692d5d..1c8ef3f 100644 --- a/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py @@ -6,8 +6,8 @@ from apps.chess.components.misc_ui import modal_container from apps.chess.components.svg_icons import ICON_SVG_CONFIRM from apps.chess.models import UserPrefsBoardTextureChoices, UserPrefsGameSpeedChoices +from apps.webui.components import common_styles -from .common_styles import BUTTON_CONFIRM_CLASSES from .svg_icons import ICON_SVG_COG if TYPE_CHECKING: @@ -69,7 +69,7 @@ def _user_prefs_form(user_prefs: "UserPrefs") -> "dom_tag": "Save preferences", " ", ICON_SVG_CONFIRM, - cls=BUTTON_CONFIRM_CLASSES, + cls=common_styles.BUTTON_CONFIRM_CLASSES, ), ) diff --git a/src/apps/daily_challenge/cookie_helpers.py b/src/apps/daily_challenge/cookie_helpers.py index e01a1d8..33041d0 100644 --- a/src/apps/daily_challenge/cookie_helpers.py +++ b/src/apps/daily_challenge/cookie_helpers.py @@ -15,8 +15,10 @@ _PLAYER_CONTENT_SESSION_KEY = "pc" -_USER_PREFS_COOKIE_NAME = "uprefs" -_USER_PREFS_COOKIE_MAX_AGE = 3600 * 24 * 30 * 6 # approximately 6 months +_USER_PREFS_COOKIE = { + "name": "uprefs", + "max-age": 3600 * 24 * 30 * 6, # approximately 6 months +} _logger = logging.getLogger(__name__) @@ -98,7 +100,7 @@ def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs: def new_content(): return UserPrefs() - cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_NAME) + cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE["name"]) if cookie_content is None or len(cookie_content) < 5: return new_content() @@ -125,10 +127,11 @@ def save_daily_challenge_state_in_session( def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None: response.set_cookie( - _USER_PREFS_COOKIE_NAME, + _USER_PREFS_COOKIE["name"], user_prefs.to_cookie_content(), - max_age=_USER_PREFS_COOKIE_MAX_AGE, + max_age=_USER_PREFS_COOKIE["max-age"], httponly=True, + samesite="Lax", ) diff --git a/src/apps/daily_challenge/view_helpers.py b/src/apps/daily_challenge/view_helpers.py index 4e2d028..9a06ce7 100644 --- a/src/apps/daily_challenge/view_helpers.py +++ b/src/apps/daily_challenge/view_helpers.py @@ -1,11 +1,8 @@ import dataclasses from typing import TYPE_CHECKING, cast +from . import cookie_helpers from .business_logic import manage_new_daily_challenge_stats_logic -from .cookie_helpers import ( - get_or_create_daily_challenge_state_for_player, - get_user_prefs_from_request, -) if TYPE_CHECKING: from django.http import HttpRequest @@ -38,10 +35,12 @@ class GameContext: def create_from_request(cls, request: "HttpRequest") -> "GameContext": is_staff_user: bool = request.user.is_staff challenge, is_preview = get_current_daily_challenge_or_admin_preview(request) - game_state, stats, created = get_or_create_daily_challenge_state_for_player( - request=request, challenge=challenge + game_state, stats, created = ( + cookie_helpers.get_or_create_daily_challenge_state_for_player( + request=request, challenge=challenge + ) ) - user_prefs = get_user_prefs_from_request(request) + user_prefs = cookie_helpers.get_user_prefs_from_request(request) # TODO: validate the "board_id" data? board_id = cast(str, request.GET.get("board_id", "main")) diff --git a/src/apps/lichess_bridge/authentication.py b/src/apps/lichess_bridge/authentication.py index a8e611a..c9a7128 100644 --- a/src/apps/lichess_bridge/authentication.py +++ b/src/apps/lichess_bridge/authentication.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: from typing import Self + from .models import LichessAccessToken + LICHESS_OAUTH2_SCOPES = ("board:play",) @@ -91,7 +93,7 @@ def create_afresh( class LichessToken(msgspec.Struct): token_type: Literal["Bearer"] - access_token: str + access_token: "LichessAccessToken" expires_in: int # number of seconds expires_at: int # a Unix timestamp diff --git a/src/apps/lichess_bridge/components/misc_ui.py b/src/apps/lichess_bridge/components/misc_ui.py new file mode 100644 index 0000000..99d44aa --- /dev/null +++ b/src/apps/lichess_bridge/components/misc_ui.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import button, form + +from apps.webui.components import common_styles +from apps.webui.components.forms_common import csrf_hidden_input + +from .svg_icons import ICON_SVG_LOG_OUT + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def detach_lichess_account_form(request: "HttpRequest") -> form: + return form( + csrf_hidden_input(request), + button( + "Log out from Lichess", + " ", + ICON_SVG_LOG_OUT, + cls=common_styles.BUTTON_CLASSES, + ), + action=reverse("lichess_bridge:detach_lichess_account"), + method="POST", + ) diff --git a/src/apps/lichess_bridge/components/pages/lichess.py b/src/apps/lichess_bridge/components/pages/lichess.py index 2553336..0e7ac47 100644 --- a/src/apps/lichess_bridge/components/pages/lichess.py +++ b/src/apps/lichess_bridge/components/pages/lichess.py @@ -1,35 +1,68 @@ from typing import TYPE_CHECKING -from dominate.tags import section -from dominate.util import raw +from django.urls import reverse +from dominate.tags import button, div, form, p, section -from apps.lichess_bridge.authentication import ( - get_lichess_token_retrieval_via_oauth2_process_starting_url, -) +from apps.webui.components import common_styles +from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page +from ...lichess_api import get_lichess_api_client +from ..misc_ui import detach_lichess_account_form +from ..svg_icons import ICON_SVG_LOG_IN + if TYPE_CHECKING: from django.http import HttpRequest - from apps.lichess_bridge.authentication import ( - LichessTokenRetrievalProcessContext, - ) + from ...models import LichessAccessToken def lichess_no_account_linked_page( *, request: "HttpRequest", - lichess_oauth2_process_context: "LichessTokenRetrievalProcessContext", ) -> str: - target_url = get_lichess_token_retrieval_via_oauth2_process_starting_url( - context=lichess_oauth2_process_context - ) - return page( section( - raw(f"""Click here: {target_url}"""), + form( + csrf_hidden_input(request), + p("Click here to log in to Lichess"), + button( + "Log in via Lichess", + " ", + ICON_SVG_LOG_IN, + type="submit", + cls=common_styles.BUTTON_CLASSES, + ), + action=reverse("lichess_bridge:oauth2_start_flow"), + method="POST", + ), cls="text-slate-50", ), request=request, title="Lichess - no account linked", ) + + +def lichess_account_linked_homepage( + *, + request: "HttpRequest", + access_token: "LichessAccessToken", +) -> str: + me = get_lichess_api_client(access_token).account.get() + + return page( + div( + section( + f'Hello {me["username"]}!', + cls="text-slate-50", + ), + div( + detach_lichess_account_form(request), + cls="mt-4", + ), + cls="w-full mx-auto bg-slate-900 min-h-48 " + "md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", + ), + request=request, + title="Lichess - account linked", + ) diff --git a/src/apps/lichess_bridge/components/svg_icons.py b/src/apps/lichess_bridge/components/svg_icons.py new file mode 100644 index 0000000..2f0b679 --- /dev/null +++ b/src/apps/lichess_bridge/components/svg_icons.py @@ -0,0 +1,15 @@ +from dominate.util import raw + +# https://heroicons.com/, icon `user` +ICON_SVG_LOG_IN = raw( + r""" + + """ +) + +# https://heroicons.com/, icon `arrow-left-start-on-rectangle` +ICON_SVG_LOG_OUT = raw( + r""" + + """ +) diff --git a/src/apps/lichess_bridge/cookie_helpers.py b/src/apps/lichess_bridge/cookie_helpers.py index b4c80fa..763f321 100644 --- a/src/apps/lichess_bridge/cookie_helpers.py +++ b/src/apps/lichess_bridge/cookie_helpers.py @@ -1,16 +1,30 @@ import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast +from django.core.exceptions import SuspiciousOperation from msgspec import MsgspecError from .authentication import LichessTokenRetrievalProcessContext +from .models import LICHESS_ACCESS_TOKEN_PREFIX if TYPE_CHECKING: from django.http import HttpRequest, HttpResponse -_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME = "lichess.oauth2.ctx" -# One day should be more than enough to let the user grant their authorisation: -_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_MAX_AGE = 3600 * 24 + from .authentication import LichessToken + from .models import LichessAccessToken + +_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE = { + "name": "lichess.oauth2.ctx", + # One day should be more than enough to let the user grant their authorisation: + "max-age": 3600 * 24, +} + +_API_ACCESS_TOKEN_COOKIE = { + "name": "lichess.access_token", + # Access tokens delivered by Lichess "are long-lived (expect one year)". + # Let's store them for approximately 6 months, on our end: + "max-age": 3600 * 24 * 30 * 6, +} _logger = logging.getLogger(__name__) @@ -20,14 +34,15 @@ def store_oauth2_token_retrieval_context_in_response_cookie( *, context: LichessTokenRetrievalProcessContext, response: "HttpResponse" ) -> None: """ - Store OAuth2 token retrieval context into a response cookie. + Store OAuth2 token retrieval context into a short-lived response cookie. """ response.set_cookie( - _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME, + _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"], context.to_cookie_content(), - max_age=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_MAX_AGE, + max_age=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["max-age"], httponly=True, + samesite="Lax", ) @@ -35,10 +50,10 @@ def get_oauth2_token_retrieval_context_from_request( request: "HttpRequest", ) -> LichessTokenRetrievalProcessContext | None: """ - Returns a context created from the "CSRF state" and "code verifier" found in the request's cookie. + Returns a context created from the "CSRF state" and "code verifier" found in the request's cookies. """ cookie_content: str | None = request.COOKIES.get( - _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME + _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"] ) if not cookie_content: return None @@ -58,4 +73,49 @@ def get_oauth2_token_retrieval_context_from_request( def delete_oauth2_token_retrieval_context_from_cookies( response: "HttpResponse", ) -> None: - response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_NAME) + response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"]) + + +def store_lichess_api_access_token_in_response_cookie( + *, token: "LichessToken", response: "HttpResponse" +) -> None: + """ + Store a Lichess API token into a long-lived response cookie. + """ + + response.set_cookie( + _API_ACCESS_TOKEN_COOKIE["name"], + token.access_token, + # TODO: should we use the token's `expires_in` here, rather than our custom + # expiry period? There are pros and cons, let's decide that later :-) + max_age=_API_ACCESS_TOKEN_COOKIE["max-age"], + httponly=True, + samesite="Lax", + ) + + +def get_lichess_api_access_token_from_request( + request: "HttpRequest", +) -> "LichessAccessToken | None": + """ + Returns a Lichess API token found in the request's cookies. + """ + cookie_content: str | None = request.COOKIES.get(_API_ACCESS_TOKEN_COOKIE["name"]) + if not cookie_content: + return None + + if ( + not cookie_content.startswith(LICHESS_ACCESS_TOKEN_PREFIX) + or len(cookie_content) < 10 + ): + raise SuspiciousOperation( + f"Suspicious Lichess API token value '{cookie_content}'" + ) + + return cast("LichessAccessToken", cookie_content) + + +def delete_lichess_api_access_token_from_cookies( + response: "HttpResponse", +) -> None: + response.delete_cookie(_API_ACCESS_TOKEN_COOKIE["name"]) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py new file mode 100644 index 0000000..d94c1c8 --- /dev/null +++ b/src/apps/lichess_bridge/lichess_api.py @@ -0,0 +1,16 @@ +from typing import TYPE_CHECKING + +import berserk + +if TYPE_CHECKING: + from .models import LichessAccessToken + + +def get_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: + return _create_lichess_api_client(access_token) + + +# This is the function we'll mock during tests: +def _create_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: + session = berserk.TokenSession(access_token) + return berserk.Client(session=session) diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py new file mode 100644 index 0000000..833c3b2 --- /dev/null +++ b/src/apps/lichess_bridge/models.py @@ -0,0 +1,8 @@ +from typing import TypeAlias + +LichessAccessToken: TypeAlias = str + +# > By convention tokens have a recognizable prefix, but do not rely on this. +# Let's still rely on this for now ^^ +# TODO: remove this, as it may break one day? +LICHESS_ACCESS_TOKEN_PREFIX = "lio_" diff --git a/src/apps/lichess_bridge/tests/__init__.py b/src/apps/lichess_bridge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py new file mode 100644 index 0000000..e8c0c5a --- /dev/null +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -0,0 +1,39 @@ +from http import HTTPStatus +from typing import TYPE_CHECKING +from unittest import mock + +if TYPE_CHECKING: + from django.test import Client as DjangoClient + + +def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): + """Just a quick smoke test for now""" + + response = client.get("/lichess/") + assert response.status_code == HTTPStatus.OK + + response_html = response.content.decode("utf-8") + assert "Log in via Lichess" in response_html + assert "Log out from Lichess" not in response_html + + +@mock.patch("apps.lichess_bridge.lichess_api._create_lichess_api_client") +def test_lichess_homepage_with_access_token_smoke_test( + create_lichess_api_client_mock: mock.MagicMock, client: "DjangoClient" +): + """Just a quick smoke test for now""" + + create_lichess_api_client_mock.return_value.account.get.return_value = { + "username": "ChessChampion" + } + + client.cookies["lichess.access_token"] = "lio_123456" + response = client.get("/lichess/") + assert response.status_code == HTTPStatus.OK + + response_html = response.content.decode("utf-8") + assert "Log in via Lichess" not in response_html + assert "Log out from Lichess" in response_html + assert "ChessChampion" in response_html + + create_lichess_api_client_mock.assert_called_once_with("lio_123456") diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index 85d8442..1e8f324 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -7,8 +7,18 @@ urlpatterns = [ path("", views.lichess_home, name="homepage"), path( - "webhook/oauth2/token-callback/", + "oauth2/start-flow/", + views.lichess_redirect_to_oauth2_flow_starting_url, + name="oauth2_start_flow", + ), + path( + "oauth2/webhook/token-callback/", views.lichess_webhook_oauth2_token_callback, name="oauth2_token_callback", ), + path( + "account/detach/", + views.lichess_detach_account, + name="detach_lichess_account", + ), ] diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index c91551a..4908ded 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -1,39 +1,58 @@ from typing import TYPE_CHECKING -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import redirect -from django.views.decorators.http import require_GET +from django.views.decorators.http import require_GET, require_POST +from . import cookie_helpers from .authentication import ( LichessTokenRetrievalProcessContext, extract_lichess_token_from_oauth2_callback_url, + get_lichess_token_retrieval_via_oauth2_process_starting_url, ) -from .components.pages.lichess import lichess_no_account_linked_page -from .cookie_helpers import ( - delete_oauth2_token_retrieval_context_from_cookies, - get_oauth2_token_retrieval_context_from_request, - store_oauth2_token_retrieval_context_in_response_cookie, +from .components.pages.lichess import ( + lichess_account_linked_homepage, + lichess_no_account_linked_page, ) if TYPE_CHECKING: from django.http import HttpRequest +@require_GET def lichess_home(request: "HttpRequest") -> HttpResponse: - lichess_oauth2_process_context = LichessTokenRetrievalProcessContext.create_afresh( - zakuchess_hostname=request.get_host(), - zakuchess_protocol=request.scheme, + # Do we have a Lichess API token for this user? + lichess_access_token = cookie_helpers.get_lichess_api_access_token_from_request( + request ) - response = HttpResponse( - lichess_no_account_linked_page( + if not lichess_access_token: + page_content = lichess_no_account_linked_page(request=request) + else: + page_content = lichess_account_linked_homepage( request=request, - lichess_oauth2_process_context=lichess_oauth2_process_context, + access_token=lichess_access_token, ) + + return HttpResponse(page_content) + + +@require_POST +def lichess_redirect_to_oauth2_flow_starting_url( + request: "HttpRequest", +) -> HttpResponse: + lichess_oauth2_process_context = LichessTokenRetrievalProcessContext.create_afresh( + zakuchess_hostname=request.get_host(), + zakuchess_protocol=request.scheme, ) + target_url = get_lichess_token_retrieval_via_oauth2_process_starting_url( + context=lichess_oauth2_process_context + ) + + response = HttpResponseRedirect(target_url) # We will need to re-use some of this context's data in the webhook below: # --> let's store that in an HTTP-only cookie - store_oauth2_token_retrieval_context_in_response_cookie( + cookie_helpers.store_oauth2_token_retrieval_context_in_response_cookie( context=lichess_oauth2_process_context, response=response ) @@ -43,8 +62,8 @@ def lichess_home(request: "HttpRequest") -> HttpResponse: @require_GET def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpResponse: # Retrieve a context from the HTTP-only cookie we created above: - lichess_oauth2_process_context = get_oauth2_token_retrieval_context_from_request( - request + lichess_oauth2_process_context = ( + cookie_helpers.get_oauth2_token_retrieval_context_from_request(request) ) if lichess_oauth2_process_context is None: # TODO: Do something with that error @@ -55,7 +74,25 @@ def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpRespons context=lichess_oauth2_process_context, ) - response = HttpResponse(f"{token=}") - delete_oauth2_token_retrieval_context_from_cookies(response) + response = redirect("lichess_bridge:homepage") + + # OAuth2 flow is done: let's delete the cookie related to this flow: + cookie_helpers.delete_oauth2_token_retrieval_context_from_cookies(response) + + # Now that we have an access token to interact with Lichess' API on behalf + # of the user, let's store it into a HTTP-only cookie: + cookie_helpers.store_lichess_api_access_token_in_response_cookie( + token=token, + response=response, + ) + + return response + + +@require_POST +def lichess_detach_account(request: "HttpRequest") -> HttpResponse: + response = redirect("lichess_bridge:homepage") + + cookie_helpers.delete_lichess_api_access_token_from_cookies(response=response) return response diff --git a/src/apps/daily_challenge/components/misc_ui/common_styles.py b/src/apps/webui/components/common_styles.py similarity index 100% rename from src/apps/daily_challenge/components/misc_ui/common_styles.py rename to src/apps/webui/components/common_styles.py diff --git a/src/apps/webui/components/forms_common.py b/src/apps/webui/components/forms_common.py new file mode 100644 index 0000000..9168859 --- /dev/null +++ b/src/apps/webui/components/forms_common.py @@ -0,0 +1,13 @@ +from typing import TYPE_CHECKING + +from django.middleware.csrf import get_token as get_csrf_token +from dominate.tags import input_ + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def csrf_hidden_input(request: "HttpRequest") -> input_: + return input_( + type="hidden", name="csrfmiddlewaretoken", value=get_csrf_token(request) + ) diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index f38021e..6b33efe 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from django.conf import settings -from django.template.backends.utils import get_token # type: ignore[attr-defined] +from django.template.backends.utils import get_token as get_csrf_token from django.templatetags.static import static from dominate.tags import ( a, @@ -94,7 +94,7 @@ def document( modals_container(), cls=_DOCUMENT_BG_COLOR, data_hx_headers=json.dumps( - {"X-CSRFToken": get_token(request) if request else "[no request]"} + {"X-CSRFToken": get_csrf_token(request) if request else "[no request]"} ), data_hx_ext="class-tools", # enable CSS class transitions on the whole page ), diff --git a/uv.lock b/uv.lock index a659673..2411bbf 100644 --- a/uv.lock +++ b/uv.lock @@ -1095,6 +1095,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287 }, ] +[[package]] +name = "pytest-blockage" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/21/10240e4d3d94b04e0caf66164ff22ae2060483bd1d6fb935bacb5ef49ec8/pytest-blockage-0.2.4.tar.gz", hash = "sha256:7127b9251242dfce7acce3f9f619727d06d22368249289c9cd7396134133c9a8", size = 3393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/6b/8f30fd11bf57cff94079d00fb1c80661d539bfb3e5b3ffa2a8217fea69c5/pytest_blockage-0.2.4-py3-none-any.whl", hash = "sha256:b853f2259a290f079918cb886fb5f3e3fdd9e5e677d6aee60a580012daf9bf91", size = 3742 }, +] + [[package]] name = "pytest-cov" version = "4.1.0" @@ -1601,6 +1613,7 @@ load-testing = [ ] test = [ { name = "pytest" }, + { name = "pytest-blockage" }, { name = "pytest-cov" }, { name = "pytest-django" }, { name = "time-machine" }, @@ -1627,6 +1640,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = "==1.*" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.*" }, { name = "pytest", marker = "extra == 'test'", specifier = "==7.*" }, + { name = "pytest-blockage", marker = "extra == 'test'", specifier = ">=0.2.4" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==4.*" }, { name = "pytest-django", marker = "extra == 'test'", specifier = "==4.*" }, { name = "python-dotenv", marker = "extra == 'dev'", specifier = "==1.*" }, From cf01eca58cd217354c78818dec9d641f5acb8863 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 6 Sep 2024 10:13:51 +0100 Subject: [PATCH 04/42] [chore] Better cookies management, with a HttpCookieAttributes struct --- src/apps/daily_challenge/admin.py | 1 + src/apps/daily_challenge/cookie_helpers.py | 28 ++++--- src/apps/daily_challenge/models.py | 2 +- src/apps/lichess_bridge/cookie_helpers.py | 79 +++++++++++-------- src/apps/lichess_bridge/lichess_api.py | 6 ++ ...o_helpers.py => django_choices_helpers.py} | 0 src/lib/http_cookies_helpers.py | 26 ++++++ src/project/settings/_base.py | 2 +- 8 files changed, 97 insertions(+), 47 deletions(-) rename src/lib/{django_helpers.py => django_choices_helpers.py} (100%) create mode 100644 src/lib/http_cookies_helpers.py diff --git a/src/apps/daily_challenge/admin.py b/src/apps/daily_challenge/admin.py index 829b97b..d5c5e4a 100644 --- a/src/apps/daily_challenge/admin.py +++ b/src/apps/daily_challenge/admin.py @@ -199,6 +199,7 @@ def play_future_daily_challenge_view( lookup_key, expires=now() + _FUTURE_DAILY_CHALLENGE_COOKIE_DURATION, httponly=True, + samesite="Lax", ) return response diff --git a/src/apps/daily_challenge/cookie_helpers.py b/src/apps/daily_challenge/cookie_helpers.py index 33041d0..25b3259 100644 --- a/src/apps/daily_challenge/cookie_helpers.py +++ b/src/apps/daily_challenge/cookie_helpers.py @@ -1,3 +1,4 @@ +import datetime as dt import logging from typing import TYPE_CHECKING, NamedTuple @@ -5,6 +6,10 @@ from msgspec import MsgspecError from apps.chess.models import UserPrefs +from lib.http_cookies_helpers import ( + HttpCookieAttributes, + set_http_cookie_on_django_response, +) from .models import PlayerGameState, PlayerSessionContent, PlayerStats @@ -15,10 +20,13 @@ _PLAYER_CONTENT_SESSION_KEY = "pc" -_USER_PREFS_COOKIE = { - "name": "uprefs", - "max-age": 3600 * 24 * 30 * 6, # approximately 6 months -} + +_USER_PREFS_COOKIE_ATTRS = HttpCookieAttributes( + name="uprefs", + max_age=dt.timedelta(days=30 * 6), # approximately 6 months + http_only=True, + same_site="Lax", +) _logger = logging.getLogger(__name__) @@ -100,7 +108,7 @@ def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs: def new_content(): return UserPrefs() - cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE["name"]) + cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_ATTRS.name) if cookie_content is None or len(cookie_content) < 5: return new_content() @@ -126,12 +134,10 @@ def save_daily_challenge_state_in_session( def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None: - response.set_cookie( - _USER_PREFS_COOKIE["name"], - user_prefs.to_cookie_content(), - max_age=_USER_PREFS_COOKIE["max-age"], - httponly=True, - samesite="Lax", + set_http_cookie_on_django_response( + response=response, + attributes=_USER_PREFS_COOKIE_ATTRS, + value=user_prefs.to_cookie_content(), ) diff --git a/src/apps/daily_challenge/models.py b/src/apps/daily_challenge/models.py index 1343f6d..765552e 100644 --- a/src/apps/daily_challenge/models.py +++ b/src/apps/daily_challenge/models.py @@ -17,7 +17,7 @@ PieceRoleBySquare, PlayerSide, ) -from lib.django_helpers import literal_to_django_choices +from lib.django_choices_helpers import literal_to_django_choices from .consts import BOT_SIDE, FACTIONS, PLAYER_SIDE diff --git a/src/apps/lichess_bridge/cookie_helpers.py b/src/apps/lichess_bridge/cookie_helpers.py index 763f321..d917a60 100644 --- a/src/apps/lichess_bridge/cookie_helpers.py +++ b/src/apps/lichess_bridge/cookie_helpers.py @@ -1,11 +1,17 @@ +import datetime as dt import logging from typing import TYPE_CHECKING, cast from django.core.exceptions import SuspiciousOperation from msgspec import MsgspecError +from lib.http_cookies_helpers import ( + HttpCookieAttributes, + set_http_cookie_on_django_response, +) + from .authentication import LichessTokenRetrievalProcessContext -from .models import LICHESS_ACCESS_TOKEN_PREFIX +from .lichess_api import is_lichess_api_access_token_valid if TYPE_CHECKING: from django.http import HttpRequest, HttpResponse @@ -13,18 +19,24 @@ from .authentication import LichessToken from .models import LichessAccessToken -_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE = { - "name": "lichess.oauth2.ctx", - # One day should be more than enough to let the user grant their authorisation: - "max-age": 3600 * 24, -} - -_API_ACCESS_TOKEN_COOKIE = { - "name": "lichess.access_token", +_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS = HttpCookieAttributes( + name="lichess.oauth2.ctx", + # This cookie only has to be valid while the user is redirected to Lichess + # and press the "Authorize" button there. + max_age=dt.timedelta(hours=1), + http_only=True, + same_site="Lax", +) + +_API_ACCESS_TOKEN_COOKIE_ATTRS = HttpCookieAttributes( + name="lichess.access_token", # Access tokens delivered by Lichess "are long-lived (expect one year)". - # Let's store them for approximately 6 months, on our end: - "max-age": 3600 * 24 * 30 * 6, -} + # As Lichess gives us the expiry date of the tokens it gives us, we can use that + # for our own cookie - so no "max-age" entry here, but we'll specify one at runtime. + max_age=None, + http_only=True, + same_site="Lax", +) _logger = logging.getLogger(__name__) @@ -37,12 +49,10 @@ def store_oauth2_token_retrieval_context_in_response_cookie( Store OAuth2 token retrieval context into a short-lived response cookie. """ - response.set_cookie( - _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"], - context.to_cookie_content(), - max_age=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["max-age"], - httponly=True, - samesite="Lax", + set_http_cookie_on_django_response( + response=response, + attributes=_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS, + value=context.to_cookie_content(), ) @@ -53,7 +63,7 @@ def get_oauth2_token_retrieval_context_from_request( Returns a context created from the "CSRF state" and "code verifier" found in the request's cookies. """ cookie_content: str | None = request.COOKIES.get( - _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"] + _OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name ) if not cookie_content: return None @@ -73,7 +83,7 @@ def get_oauth2_token_retrieval_context_from_request( def delete_oauth2_token_retrieval_context_from_cookies( response: "HttpResponse", ) -> None: - response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE["name"]) + response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name) def store_lichess_api_access_token_in_response_cookie( @@ -82,15 +92,17 @@ def store_lichess_api_access_token_in_response_cookie( """ Store a Lichess API token into a long-lived response cookie. """ + # TODO: use a secured cookie here? + + # Our cookie will expire when the access token given by Lichess will: + cookie_attributes = _API_ACCESS_TOKEN_COOKIE_ATTRS._replace( + max_age=dt.timedelta(seconds=token.expires_in), + ) - response.set_cookie( - _API_ACCESS_TOKEN_COOKIE["name"], - token.access_token, - # TODO: should we use the token's `expires_in` here, rather than our custom - # expiry period? There are pros and cons, let's decide that later :-) - max_age=_API_ACCESS_TOKEN_COOKIE["max-age"], - httponly=True, - samesite="Lax", + set_http_cookie_on_django_response( + response=response, + attributes=cookie_attributes, + value=token.access_token, ) @@ -100,14 +112,13 @@ def get_lichess_api_access_token_from_request( """ Returns a Lichess API token found in the request's cookies. """ - cookie_content: str | None = request.COOKIES.get(_API_ACCESS_TOKEN_COOKIE["name"]) + cookie_content: str | None = request.COOKIES.get( + _API_ACCESS_TOKEN_COOKIE_ATTRS.name + ) if not cookie_content: return None - if ( - not cookie_content.startswith(LICHESS_ACCESS_TOKEN_PREFIX) - or len(cookie_content) < 10 - ): + if not is_lichess_api_access_token_valid(cookie_content): raise SuspiciousOperation( f"Suspicious Lichess API token value '{cookie_content}'" ) @@ -118,4 +129,4 @@ def get_lichess_api_access_token_from_request( def delete_lichess_api_access_token_from_cookies( response: "HttpResponse", ) -> None: - response.delete_cookie(_API_ACCESS_TOKEN_COOKIE["name"]) + response.delete_cookie(_API_ACCESS_TOKEN_COOKIE_ATTRS.name) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index d94c1c8..568f0cf 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -2,10 +2,16 @@ import berserk +from .models import LICHESS_ACCESS_TOKEN_PREFIX + if TYPE_CHECKING: from .models import LichessAccessToken +def is_lichess_api_access_token_valid(token: str) -> bool: + return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10 + + def get_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: return _create_lichess_api_client(access_token) diff --git a/src/lib/django_helpers.py b/src/lib/django_choices_helpers.py similarity index 100% rename from src/lib/django_helpers.py rename to src/lib/django_choices_helpers.py diff --git a/src/lib/http_cookies_helpers.py b/src/lib/http_cookies_helpers.py new file mode 100644 index 0000000..54916e0 --- /dev/null +++ b/src/lib/http_cookies_helpers.py @@ -0,0 +1,26 @@ +from typing import TYPE_CHECKING, Literal, NamedTuple + +if TYPE_CHECKING: + import datetime as dt + + from django.http import HttpResponse + + +class HttpCookieAttributes(NamedTuple): + name: str + max_age: "dt.timedelta | None" + http_only: bool + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + same_site: Literal["Strict", "Lax", "None", None] = "Lax" + + +def set_http_cookie_on_django_response( + *, response: "HttpResponse", attributes: HttpCookieAttributes, value: str +) -> None: + response.set_cookie( + attributes.name, + value, + max_age=attributes.max_age, + httponly=attributes.http_only, + samesite=attributes.same_site, + ) diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index 6a1e6a9..f3349a5 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -205,5 +205,5 @@ # > (no client authentication, choose any unique client id). # So it's not a kind of API secret we would have created on Lichess' side, but just an # arbitrary identifier. -LICHESS_CLIENT_ID = env.get("LICHESS_CLIENT_ID", "") +LICHESS_CLIENT_ID = env.get("LICHESS_CLIENT_ID", "zakuchess.com") LICHESS_HOST = env.get("LICHESS_HOST", "https://lichess.org") From 9e045c5db8eeafbc72436baf83d43683af34219d Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 6 Sep 2024 10:14:40 +0100 Subject: [PATCH 05/42] [lichess] Check the CSRF state, rather than ignoring that aspect of OAuth2 for now --- src/apps/lichess_bridge/authentication.py | 24 +++++++++++++++++---- src/apps/lichess_bridge/tests/test_views.py | 4 ++-- src/apps/lichess_bridge/views.py | 14 +++++++++--- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/apps/lichess_bridge/authentication.py b/src/apps/lichess_bridge/authentication.py index c9a7128..bd93917 100644 --- a/src/apps/lichess_bridge/authentication.py +++ b/src/apps/lichess_bridge/authentication.py @@ -15,11 +15,14 @@ from authlib.common.security import generate_token from authlib.integrations.requests_client import OAuth2Session from django.conf import settings +from django.core.exceptions import SuspiciousOperation from django.urls import reverse if TYPE_CHECKING: from typing import Self + from django.http import HttpRequest + from .models import LichessAccessToken LICHESS_OAUTH2_SCOPES = ("board:play",) @@ -104,7 +107,7 @@ def get_lichess_token_retrieval_via_oauth2_process_starting_url( ) -> str: lichess_authorization_endpoint = f"{settings.LICHESS_HOST}/oauth" - client = _get_lichess_client() + client = _get_lichess_oauth2_client() uri, state = client.create_authorization_url( lichess_authorization_endpoint, response_type="code", @@ -117,14 +120,27 @@ def get_lichess_token_retrieval_via_oauth2_process_starting_url( return uri -def extract_lichess_token_from_oauth2_callback_url( +def check_csrf_state_from_oauth2_callback( + *, request: "HttpRequest", context: LichessTokenRetrievalProcessContext +): + """ + Raises a SuspiciousOperation if the state from the request's query string + doesn't match the state from the short-lived cookie. + """ + csrf_state_from_request = request.GET["state"] + csrf_state_from_short_lived_cookie = context.csrf_state + if csrf_state_from_short_lived_cookie != csrf_state_from_request: + raise SuspiciousOperation("OAuth2 CSRF state mismatch") + + +def fetch_lichess_token_from_oauth2_callback( *, authorization_callback_response_url: str, context: LichessTokenRetrievalProcessContext, ) -> LichessToken: lichess_token_endpoint = f"{settings.LICHESS_HOST}/api/token" - client = _get_lichess_client() + client = _get_lichess_oauth2_client() token_as_dict = client.fetch_token( lichess_token_endpoint, authorization_response=authorization_callback_response_url, @@ -146,7 +162,7 @@ def _get_lichess_oauth2_zakuchess_redirect_uri( ) -def _get_lichess_client() -> OAuth2Session: +def _get_lichess_oauth2_client() -> OAuth2Session: return OAuth2Session( client_id=settings.LICHESS_CLIENT_ID, code_challenge_method="S256", diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index e8c0c5a..16c56be 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -27,7 +27,7 @@ def test_lichess_homepage_with_access_token_smoke_test( "username": "ChessChampion" } - client.cookies["lichess.access_token"] = "lio_123456" + client.cookies["lichess.access_token"] = "lio_123456789" response = client.get("/lichess/") assert response.status_code == HTTPStatus.OK @@ -36,4 +36,4 @@ def test_lichess_homepage_with_access_token_smoke_test( assert "Log out from Lichess" in response_html assert "ChessChampion" in response_html - create_lichess_api_client_mock.assert_called_once_with("lio_123456") + create_lichess_api_client_mock.assert_called_once_with("lio_123456789") diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 4908ded..c28af7e 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -7,7 +7,8 @@ from . import cookie_helpers from .authentication import ( LichessTokenRetrievalProcessContext, - extract_lichess_token_from_oauth2_callback_url, + check_csrf_state_from_oauth2_callback, + fetch_lichess_token_from_oauth2_callback, get_lichess_token_retrieval_via_oauth2_process_starting_url, ) from .components.pages.lichess import ( @@ -69,7 +70,14 @@ def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpRespons # TODO: Do something with that error return redirect("lichess_bridge:homepage") - token = extract_lichess_token_from_oauth2_callback_url( + # We have to check the "CSRF state": + # ( https://stack-auth.com/blog/oauth-from-first-principles#attack-4 ) + check_csrf_state_from_oauth2_callback( + request=request, context=lichess_oauth2_process_context + ) + + # Ok, now let's fetch an API access token from Lichess! + token = fetch_lichess_token_from_oauth2_callback( authorization_callback_response_url=request.get_full_path(), context=lichess_oauth2_process_context, ) @@ -80,7 +88,7 @@ def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpRespons cookie_helpers.delete_oauth2_token_retrieval_context_from_cookies(response) # Now that we have an access token to interact with Lichess' API on behalf - # of the user, let's store it into a HTTP-only cookie: + # of the user, let's store it into a long-lived HTTP-only cookie: cookie_helpers.store_lichess_api_access_token_in_response_cookie( token=token, response=response, From 61e07d76dbda2df762e5bae4d3dc21b5cb514296 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 6 Sep 2024 10:15:02 +0100 Subject: [PATCH 06/42] [chore] misc uv-related fixes & improvements --- .gitignore | 3 +-- uv.lock | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index c803548..2e0adce 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,7 @@ # uv-related stuff: /bin/uv /bin/uvx -/.uv.env -/.uv.env.fish +*.egg-info /.docker/* !/.docker/.gitkeep diff --git a/uv.lock b/uv.lock index 2411bbf..f3ceee6 100644 --- a/uv.lock +++ b/uv.lock @@ -1640,7 +1640,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = "==1.*" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.*" }, { name = "pytest", marker = "extra == 'test'", specifier = "==7.*" }, - { name = "pytest-blockage", marker = "extra == 'test'", specifier = ">=0.2.4" }, + { name = "pytest-blockage", marker = "extra == 'test'", specifier = "==0.2.*" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==4.*" }, { name = "pytest-django", marker = "extra == 'test'", specifier = "==4.*" }, { name = "python-dotenv", marker = "extra == 'dev'", specifier = "==1.*" }, From 3be18beda9ed96391fa58a75e56b5ef3a56e179f Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 6 Sep 2024 10:39:06 +0100 Subject: [PATCH 07/42] [lichess] switch to async Views As Lichess-related Views will mainly be proxies to the Lichess HTTP API, we would likely benefit from being able to not block workers' threads while the I/O between our server and Lichess are taking place. --- Dockerfile | 2 +- Makefile | 10 +- pyproject.toml | 2 + .../components/pages/lichess.py | 6 +- src/apps/lichess_bridge/lichess_api.py | 10 +- src/apps/lichess_bridge/views.py | 4 +- uv.lock | 174 ++++++++++++++++++ 7 files changed, 199 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index bac4abc..8ddfb81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -179,7 +179,7 @@ EXPOSE 8080 ENV DJANGO_SETTINGS_MODULE=project.settings.production -ENV GUNICORN_CMD_ARGS="--bind 0.0.0.0:8080 --workers 2 --max-requests 120 --max-requests-jitter 20 --timeout 8" +ENV GUNICORN_CMD_ARGS="--bind 0.0.0.0:8080 --workers 4 -k uvicorn_worker.UvicornWorker --max-requests 120 --max-requests-jitter 20 --timeout 8" RUN chmod +x scripts/start_server.sh # See . diff --git a/Makefile b/Makefile index 6958c1b..893e33b 100644 --- a/Makefile +++ b/Makefile @@ -38,11 +38,17 @@ backend/install: bin/uv .venv ## Install the Python dependencies (via uv) and in @${SUB_MAKE} .venv/bin/black .PHONY: backend/watch +backend/watch: env_vars ?= backend/watch: address ?= localhost backend/watch: port ?= 8000 backend/watch: dotenv_file ?= .env.local -backend/watch: ## Start the Django development server - @${SUB_MAKE} django/manage cmd='runserver ${address}:${port}' +backend/watch: ## Start Django via Uvicorn, in "watch" mode + @@DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ + ${UV} run uvicorn \ + --reload --reload-dir src/ \ + --host ${address} --port ${port} \ + --env-file ${dotenv_file} \ + project.asgi:application .PHONY: backend/resetdb backend/resetdb: dotenv_file ?= .env.local diff --git a/pyproject.toml b/pyproject.toml index f24392f..636ea17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ dependencies= [ # Django doesn't follow SemVer, so we need to specify the minor version: "Django==5.1.*", "gunicorn==22.*", + "uvicorn[standard]==0.30.*", + "uvicorn-worker==0.2.*", "django-alive==1.*", "chess==1.*", "django-htmx==1.*", diff --git a/src/apps/lichess_bridge/components/pages/lichess.py b/src/apps/lichess_bridge/components/pages/lichess.py index 0e7ac47..d686ce8 100644 --- a/src/apps/lichess_bridge/components/pages/lichess.py +++ b/src/apps/lichess_bridge/components/pages/lichess.py @@ -7,7 +7,7 @@ from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page -from ...lichess_api import get_lichess_api_client +from ... import lichess_api from ..misc_ui import detach_lichess_account_form from ..svg_icons import ICON_SVG_LOG_IN @@ -43,12 +43,12 @@ def lichess_no_account_linked_page( ) -def lichess_account_linked_homepage( +async def lichess_account_linked_homepage( *, request: "HttpRequest", access_token: "LichessAccessToken", ) -> str: - me = get_lichess_api_client(access_token).account.get() + me = await lichess_api.get_lichess_my_account(access_token) return page( div( diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 568f0cf..35e0e59 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING import berserk +from asgiref.sync import sync_to_async from .models import LICHESS_ACCESS_TOKEN_PREFIX @@ -12,7 +13,14 @@ def is_lichess_api_access_token_valid(token: str) -> bool: return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10 -def get_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: +@sync_to_async(thread_sensitive=False) +def get_lichess_my_account( + access_token: "LichessAccessToken", +) -> berserk.types.AccountInformation: + return _get_lichess_api_client(access_token).account.get() + + +def _get_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: return _create_lichess_api_client(access_token) diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index c28af7e..99f58d8 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -21,7 +21,7 @@ @require_GET -def lichess_home(request: "HttpRequest") -> HttpResponse: +async def lichess_home(request: "HttpRequest") -> HttpResponse: # Do we have a Lichess API token for this user? lichess_access_token = cookie_helpers.get_lichess_api_access_token_from_request( request @@ -30,7 +30,7 @@ def lichess_home(request: "HttpRequest") -> HttpResponse: if not lichess_access_token: page_content = lichess_no_account_linked_page(request=request) else: - page_content = lichess_account_linked_homepage( + page_content = await lichess_account_linked_homepage( request=request, access_token=lichess_access_token, ) diff --git a/uv.lock b/uv.lock index f3ceee6..52a4a10 100644 --- a/uv.lock +++ b/uv.lock @@ -689,6 +689,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 }, ] +[[package]] +name = "httptools" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/d77686502fced061b3ead1c35a2d70f6b281b5f723c4eff7a2277c04e4a2/httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a", size = 191228 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d1/53283b96ed823d5e4d89ee9aa0f29df5a1bdf67f148e061549a595d534e4/httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1", size = 145855 }, + { url = "https://files.pythonhosted.org/packages/80/dd/cebc9d4b1d4b70e9f3d40d1db0829a28d57ca139d0b04197713816a11996/httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0", size = 75604 }, + { url = "https://files.pythonhosted.org/packages/76/7a/45c5a9a2e9d21f7381866eb7b6ead5a84d8fe7e54e35208eeb18320a29b4/httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc", size = 324784 }, + { url = "https://files.pythonhosted.org/packages/59/23/047a89e66045232fb82c50ae57699e40f70e073ae5ccd53f54e532fbd2a2/httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2", size = 318547 }, + { url = "https://files.pythonhosted.org/packages/82/f5/50708abc7965d7d93c0ee14a148ccc6d078a508f47fe9357c79d5360f252/httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837", size = 330211 }, + { url = "https://files.pythonhosted.org/packages/e3/1e/9823ca7aab323c0e0e9dd82ce835a6e93b69f69aedffbc94d31e327f4283/httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d", size = 322174 }, + { url = "https://files.pythonhosted.org/packages/14/e4/20d28dfe7f5b5603b6b04c33bb88662ad749de51f0c539a561f235f42666/httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3", size = 55434 }, + { url = "https://files.pythonhosted.org/packages/60/13/b62e086b650752adf9094b7e62dab97f4cb7701005664544494b7956a51e/httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0", size = 146354 }, + { url = "https://files.pythonhosted.org/packages/f8/5d/9ad32b79b6c24524087e78aa3f0a2dfcf58c11c90e090e4593b35def8a86/httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2", size = 75785 }, + { url = "https://files.pythonhosted.org/packages/d0/a4/b503851c40f20bcbd453db24ed35d961f62abdae0dccc8f672cd5d350d87/httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90", size = 345396 }, + { url = "https://files.pythonhosted.org/packages/a2/9a/aa406864f3108e06f7320425a528ff8267124dead1fd72a3e9da2067f893/httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503", size = 344741 }, + { url = "https://files.pythonhosted.org/packages/cf/3a/3fd8dfb987c4247651baf2ac6f28e8e9f889d484ca1a41a9ad0f04dfe300/httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84", size = 345096 }, + { url = "https://files.pythonhosted.org/packages/80/01/379f6466d8e2edb861c1f44ccac255ed1f8a0d4c5c666a1ceb34caad7555/httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb", size = 343535 }, + { url = "https://files.pythonhosted.org/packages/d3/97/60860e9ee87a7d4712b98f7e1411730520053b9d69e9e42b0b9751809c17/httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949", size = 55660 }, +] + [[package]] name = "httpx" version = "0.26.0" @@ -1502,6 +1524,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/1c/89ffc63a9605b583d5df2be791a27bc1a42b7c32bab68d3c8f2f73a98cd4/urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", size = 121444 }, ] +[[package]] +name = "uvicorn" +version = "0.30.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/01/5e637e7aa9dd031be5376b9fb749ec20b86f5a5b6a49b87fabd374d5fa9f/uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", size = 42825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/8e/cdc7d6263db313030e4c257dd5ba3909ebc4e4fb53ad62d5f09b1a2f5458/uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5", size = 62835 }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/7a/a4b06ea7ece47f6b020671209912a505f8eef1812e02a68cb25d71ee0e8d/uvicorn_worker-0.2.0.tar.gz", hash = "sha256:f6894544391796be6eeed37d48cae9d7739e5a105f7e37061eccef2eac5a0295", size = 8959 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/9c/5ead3efe80abb7ba5e2764650a050e7c25d8a75228543a1e63ce321186c3/uvicorn_worker-0.2.0-py3-none-any.whl", hash = "sha256:65dcef25ab80a62e0919640f9582216ee05b3bb1dc2f0e58b354ca0511c398fb", size = 5282 }, +] + +[[package]] +name = "uvloop" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/f1/dc9577455e011ad43d9379e836ee73f40b4f99c02946849a44f7ae64835e/uvloop-0.20.0.tar.gz", hash = "sha256:4603ca714a754fc8d9b197e325db25b2ea045385e8a3ad05d3463de725fdf469", size = 2329938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/bf/45828beccf685b7ed9638d9b77ef382b470c6ca3b5bff78067e02ffd5663/uvloop-0.20.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e50289c101495e0d1bb0bfcb4a60adde56e32f4449a67216a1ab2750aa84f037", size = 1320593 }, + { url = "https://files.pythonhosted.org/packages/27/c0/3c24e50bee7802a2add96ca9f0d5eb0ebab07e0a5615539d38aeb89499b9/uvloop-0.20.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e237f9c1e8a00e7d9ddaa288e535dc337a39bcbf679f290aee9d26df9e72bce9", size = 736676 }, + { url = "https://files.pythonhosted.org/packages/83/ce/ffa3c72954eae36825acfafd2b6a9221d79abd2670c0d25e04d6ef4a2007/uvloop-0.20.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:746242cd703dc2b37f9d8b9f173749c15e9a918ddb021575a0205ec29a38d31e", size = 3494573 }, + { url = "https://files.pythonhosted.org/packages/46/6d/4caab3a36199ba52b98d519feccfcf48921d7a6649daf14a93c7e77497e9/uvloop-0.20.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82edbfd3df39fb3d108fc079ebc461330f7c2e33dbd002d146bf7c445ba6e756", size = 3489932 }, + { url = "https://files.pythonhosted.org/packages/e4/4f/49c51595bd794945c88613df88922c38076eae2d7653f4624aa6f4980b07/uvloop-0.20.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:80dc1b139516be2077b3e57ce1cb65bfed09149e1d175e0478e7a987863b68f0", size = 4185596 }, + { url = "https://files.pythonhosted.org/packages/b8/94/7e256731260d313f5049717d1c4582d52a3b132424c95e16954a50ab95d3/uvloop-0.20.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4f44af67bf39af25db4c1ac27e82e9665717f9c26af2369c404be865c8818dcf", size = 4185746 }, + { url = "https://files.pythonhosted.org/packages/2d/64/31cbd379d6e260ac8de3f672f904e924f09715c3f192b09f26cc8e9f574c/uvloop-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4b75f2950ddb6feed85336412b9a0c310a2edbcf4cf931aa5cfe29034829676d", size = 1324302 }, + { url = "https://files.pythonhosted.org/packages/1e/6b/9207e7177ff30f78299401f2e1163ea41130d4fd29bcdc6d12572c06b728/uvloop-0.20.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:77fbc69c287596880ecec2d4c7a62346bef08b6209749bf6ce8c22bbaca0239e", size = 738105 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/b64b10f577519d875992dc07e2365899a1a4c0d28327059ce1e1bdfb6854/uvloop-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6462c95f48e2d8d4c993a2950cd3d31ab061864d1c226bbf0ee2f1a8f36674b9", size = 4090658 }, + { url = "https://files.pythonhosted.org/packages/0a/f8/5ceea6876154d926604f10c1dd896adf9bce6d55a55911364337b8a5ed8d/uvloop-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649c33034979273fa71aa25d0fe120ad1777c551d8c4cd2c0c9851d88fcb13ab", size = 4173357 }, + { url = "https://files.pythonhosted.org/packages/18/b2/117ab6bfb18274753fbc319607bf06e216bd7eea8be81d5bac22c912d6a7/uvloop-0.20.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a609780e942d43a275a617c0839d85f95c334bad29c4c0918252085113285b5", size = 4029868 }, + { url = "https://files.pythonhosted.org/packages/6f/52/deb4be09060637ef4752adaa0b75bf770c20c823e8108705792f99cd4a6f/uvloop-0.20.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aea15c78e0d9ad6555ed201344ae36db5c63d428818b4b2a42842b3870127c00", size = 4115980 }, +] + [[package]] name = "virtualenv" version = "20.26.3" @@ -1516,6 +1595,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/4d/410156100224c5e2f0011d435e477b57aed9576fc7fe137abcf14ec16e11/virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589", size = 5684792 }, ] +[[package]] +name = "watchfiles" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/27/2ba23c8cc85796e2d41976439b08d52f691655fdb9401362099502d1f0cf/watchfiles-0.24.0.tar.gz", hash = "sha256:afb72325b74fa7a428c009c1b8be4b4d7c2afedafb2982827ef2156646df2fe1", size = 37870 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/02/366ae902cd81ca5befcd1854b5c7477b378f68861597cef854bd6dc69fbe/watchfiles-0.24.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:bdcd5538e27f188dd3c804b4a8d5f52a7fc7f87e7fd6b374b8e36a4ca03db428", size = 375579 }, + { url = "https://files.pythonhosted.org/packages/bc/67/d8c9d256791fe312fea118a8a051411337c948101a24586e2df237507976/watchfiles-0.24.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2dadf8a8014fde6addfd3c379e6ed1a981c8f0a48292d662e27cabfe4239c83c", size = 367726 }, + { url = "https://files.pythonhosted.org/packages/b1/dc/a8427b21ef46386adf824a9fec4be9d16a475b850616cfd98cf09a97a2ef/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6509ed3f467b79d95fc62a98229f79b1a60d1b93f101e1c61d10c95a46a84f43", size = 437735 }, + { url = "https://files.pythonhosted.org/packages/3a/21/0b20bef581a9fbfef290a822c8be645432ceb05fb0741bf3c032e0d90d9a/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8360f7314a070c30e4c976b183d1d8d1585a4a50c5cb603f431cebcbb4f66327", size = 433644 }, + { url = "https://files.pythonhosted.org/packages/1c/e8/d5e5f71cc443c85a72e70b24269a30e529227986096abe091040d6358ea9/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:316449aefacf40147a9efaf3bd7c9bdd35aaba9ac5d708bd1eb5763c9a02bef5", size = 450928 }, + { url = "https://files.pythonhosted.org/packages/61/ee/bf17f5a370c2fcff49e1fec987a6a43fd798d8427ea754ce45b38f9e117a/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73bde715f940bea845a95247ea3e5eb17769ba1010efdc938ffcb967c634fa61", size = 469072 }, + { url = "https://files.pythonhosted.org/packages/a3/34/03b66d425986de3fc6077e74a74c78da298f8cb598887f664a4485e55543/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3770e260b18e7f4e576edca4c0a639f704088602e0bc921c5c2e721e3acb8d15", size = 475517 }, + { url = "https://files.pythonhosted.org/packages/70/eb/82f089c4f44b3171ad87a1b433abb4696f18eb67292909630d886e073abe/watchfiles-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa0fd7248cf533c259e59dc593a60973a73e881162b1a2f73360547132742823", size = 425480 }, + { url = "https://files.pythonhosted.org/packages/53/20/20509c8f5291e14e8a13104b1808cd7cf5c44acd5feaecb427a49d387774/watchfiles-0.24.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d7a2e3b7f5703ffbd500dabdefcbc9eafeff4b9444bbdd5d83d79eedf8428fab", size = 612322 }, + { url = "https://files.pythonhosted.org/packages/df/2b/5f65014a8cecc0a120f5587722068a975a692cadbe9fe4ea56b3d8e43f14/watchfiles-0.24.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d831ee0a50946d24a53821819b2327d5751b0c938b12c0653ea5be7dea9c82ec", size = 595094 }, + { url = "https://files.pythonhosted.org/packages/18/98/006d8043a82c0a09d282d669c88e587b3a05cabdd7f4900e402250a249ac/watchfiles-0.24.0-cp311-none-win32.whl", hash = "sha256:49d617df841a63b4445790a254013aea2120357ccacbed00253f9c2b5dc24e2d", size = 264191 }, + { url = "https://files.pythonhosted.org/packages/8a/8b/badd9247d6ec25f5f634a9b3d0d92e39c045824ec7e8afcedca8ee52c1e2/watchfiles-0.24.0-cp311-none-win_amd64.whl", hash = "sha256:d3dcb774e3568477275cc76554b5a565024b8ba3a0322f77c246bc7111c5bb9c", size = 277527 }, + { url = "https://files.pythonhosted.org/packages/af/19/35c957c84ee69d904299a38bae3614f7cede45f07f174f6d5a2f4dbd6033/watchfiles-0.24.0-cp311-none-win_arm64.whl", hash = "sha256:9301c689051a4857d5b10777da23fafb8e8e921bcf3abe6448a058d27fb67633", size = 266253 }, + { url = "https://files.pythonhosted.org/packages/35/82/92a7bb6dc82d183e304a5f84ae5437b59ee72d48cee805a9adda2488b237/watchfiles-0.24.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7211b463695d1e995ca3feb38b69227e46dbd03947172585ecb0588f19b0d87a", size = 374137 }, + { url = "https://files.pythonhosted.org/packages/87/91/49e9a497ddaf4da5e3802d51ed67ff33024597c28f652b8ab1e7c0f5718b/watchfiles-0.24.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b8693502d1967b00f2fb82fc1e744df128ba22f530e15b763c8d82baee15370", size = 367733 }, + { url = "https://files.pythonhosted.org/packages/0d/d8/90eb950ab4998effea2df4cf3a705dc594f6bc501c5a353073aa990be965/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdab9555053399318b953a1fe1f586e945bc8d635ce9d05e617fd9fe3a4687d6", size = 437322 }, + { url = "https://files.pythonhosted.org/packages/6c/a2/300b22e7bc2a222dd91fce121cefa7b49aa0d26a627b2777e7bdfcf1110b/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34e19e56d68b0dad5cff62273107cf5d9fbaf9d75c46277aa5d803b3ef8a9e9b", size = 433409 }, + { url = "https://files.pythonhosted.org/packages/99/44/27d7708a43538ed6c26708bcccdde757da8b7efb93f4871d4cc39cffa1cc/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41face41f036fee09eba33a5b53a73e9a43d5cb2c53dad8e61fa6c9f91b5a51e", size = 452142 }, + { url = "https://files.pythonhosted.org/packages/b0/ec/c4e04f755be003129a2c5f3520d2c47026f00da5ecb9ef1e4f9449637571/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5148c2f1ea043db13ce9b0c28456e18ecc8f14f41325aa624314095b6aa2e9ea", size = 469414 }, + { url = "https://files.pythonhosted.org/packages/c5/4e/cdd7de3e7ac6432b0abf282ec4c1a1a2ec62dfe423cf269b86861667752d/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e4bd963a935aaf40b625c2499f3f4f6bbd0c3776f6d3bc7c853d04824ff1c9f", size = 472962 }, + { url = "https://files.pythonhosted.org/packages/27/69/e1da9d34da7fc59db358424f5d89a56aaafe09f6961b64e36457a80a7194/watchfiles-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c79d7719d027b7a42817c5d96461a99b6a49979c143839fc37aa5748c322f234", size = 425705 }, + { url = "https://files.pythonhosted.org/packages/e8/c1/24d0f7357be89be4a43e0a656259676ea3d7a074901f47022f32e2957798/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:32aa53a9a63b7f01ed32e316e354e81e9da0e6267435c7243bf8ae0f10b428ef", size = 612851 }, + { url = "https://files.pythonhosted.org/packages/c7/af/175ba9b268dec56f821639c9893b506c69fd999fe6a2e2c51de420eb2f01/watchfiles-0.24.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce72dba6a20e39a0c628258b5c308779b8697f7676c254a845715e2a1039b968", size = 594868 }, + { url = "https://files.pythonhosted.org/packages/44/81/1f701323a9f70805bc81c74c990137123344a80ea23ab9504a99492907f8/watchfiles-0.24.0-cp312-none-win32.whl", hash = "sha256:d9018153cf57fc302a2a34cb7564870b859ed9a732d16b41a9b5cb2ebed2d444", size = 264109 }, + { url = "https://files.pythonhosted.org/packages/b4/0b/32cde5bc2ebd9f351be326837c61bdeb05ad652b793f25c91cac0b48a60b/watchfiles-0.24.0-cp312-none-win_amd64.whl", hash = "sha256:551ec3ee2a3ac9cbcf48a4ec76e42c2ef938a7e905a35b42a1267fa4b1645896", size = 277055 }, + { url = "https://files.pythonhosted.org/packages/4b/81/daade76ce33d21dbec7a15afd7479de8db786e5f7b7d249263b4ea174e08/watchfiles-0.24.0-cp312-none-win_arm64.whl", hash = "sha256:b52a65e4ea43c6d149c5f8ddb0bef8d4a1e779b77591a458a893eb416624a418", size = 266169 }, + { url = "https://files.pythonhosted.org/packages/30/dc/6e9f5447ae14f645532468a84323a942996d74d5e817837a5c8ce9d16c69/watchfiles-0.24.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3d2e3ab79a1771c530233cadfd277fcc762656d50836c77abb2e5e72b88e3a48", size = 373764 }, + { url = "https://files.pythonhosted.org/packages/79/c0/c3a9929c372816c7fc87d8149bd722608ea58dc0986d3ef7564c79ad7112/watchfiles-0.24.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327763da824817b38ad125dcd97595f942d720d32d879f6c4ddf843e3da3fe90", size = 367873 }, + { url = "https://files.pythonhosted.org/packages/2e/11/ff9a4445a7cfc1c98caf99042df38964af12eed47d496dd5d0d90417349f/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd82010f8ab451dabe36054a1622870166a67cf3fce894f68895db6f74bbdc94", size = 438381 }, + { url = "https://files.pythonhosted.org/packages/48/a3/763ba18c98211d7bb6c0f417b2d7946d346cdc359d585cc28a17b48e964b/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d64ba08db72e5dfd5c33be1e1e687d5e4fcce09219e8aee893a4862034081d4e", size = 432809 }, + { url = "https://files.pythonhosted.org/packages/30/4c/616c111b9d40eea2547489abaf4ffc84511e86888a166d3a4522c2ba44b5/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1cf1f6dd7825053f3d98f6d33f6464ebdd9ee95acd74ba2c34e183086900a827", size = 451801 }, + { url = "https://files.pythonhosted.org/packages/b6/be/d7da83307863a422abbfeb12903a76e43200c90ebe5d6afd6a59d158edea/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43e3e37c15a8b6fe00c1bce2473cfa8eb3484bbeecf3aefbf259227e487a03df", size = 468886 }, + { url = "https://files.pythonhosted.org/packages/1d/d3/3dfe131ee59d5e90b932cf56aba5c996309d94dafe3d02d204364c23461c/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88bcd4d0fe1d8ff43675360a72def210ebad3f3f72cabfeac08d825d2639b4ab", size = 472973 }, + { url = "https://files.pythonhosted.org/packages/42/6c/279288cc5653a289290d183b60a6d80e05f439d5bfdfaf2d113738d0f932/watchfiles-0.24.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:999928c6434372fde16c8f27143d3e97201160b48a614071261701615a2a156f", size = 425282 }, + { url = "https://files.pythonhosted.org/packages/d6/d7/58afe5e85217e845edf26d8780c2d2d2ae77675eeb8d1b8b8121d799ce52/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:30bbd525c3262fd9f4b1865cb8d88e21161366561cd7c9e1194819e0a33ea86b", size = 612540 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/b96eeb9fe3fda137200dd2f31553670cbc731b1e13164fd69b49870b76ec/watchfiles-0.24.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:edf71b01dec9f766fb285b73930f95f730bb0943500ba0566ae234b5c1618c18", size = 593625 }, + { url = "https://files.pythonhosted.org/packages/c1/e5/c326fe52ee0054107267608d8cea275e80be4455b6079491dfd9da29f46f/watchfiles-0.24.0-cp313-none-win32.whl", hash = "sha256:f4c96283fca3ee09fb044f02156d9570d156698bc3734252175a38f0e8975f07", size = 263899 }, + { url = "https://files.pythonhosted.org/packages/a6/8b/8a7755c5e7221bb35fe4af2dc44db9174f90ebf0344fd5e9b1e8b42d381e/watchfiles-0.24.0-cp313-none-win_amd64.whl", hash = "sha256:a974231b4fdd1bb7f62064a0565a6b107d27d21d9acb50c484d2cdba515b9366", size = 276622 }, +] + [[package]] name = "wcwidth" version = "0.2.13" @@ -1525,6 +1653,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] +[[package]] +name = "websockets" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/1c/78687e0267b09412409ac134f10fd14d14ac6475da892a8b09a02d0f6ae2/websockets-13.0.1.tar.gz", hash = "sha256:4d6ece65099411cfd9a48d13701d7438d9c34f479046b34c50ff60bb8834e43e", size = 149769 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/95/e002ec55688b751d3c9cc131c1960af7e440d95e1954c441535b9da2bf36/websockets-13.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:699ba9dd6a926f82a277063603fc8d586b89f4cb128efc353b749b641fcddda7", size = 150948 }, + { url = "https://files.pythonhosted.org/packages/62/6b/85fb8c13b278db7d45e27ff6ee0db3009b0fadef7c37c85e6cb4a0fbf08e/websockets-13.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf2fae6d85e5dc384bf846f8243ddaa9197f3a1a70044f59399af001fd1f51d4", size = 148599 }, + { url = "https://files.pythonhosted.org/packages/e8/2e/c80cafbab86f8c399ba8323efff298b7062055724146391443d266e9c49b/websockets-13.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52aed6ef21a0f1a2a5e310fb5c42d7555e9c5855476bbd7173c3aa3d8a0302f2", size = 148851 }, + { url = "https://files.pythonhosted.org/packages/2e/67/631d4b1f28fef6f12730c0cbe982203a9d6814768c2ab1e0a352d9a07a97/websockets-13.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8eb2b9a318542153674c6e377eb8cb9ca0fc011c04475110d3477862f15d29f0", size = 158509 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/ba740eab2a9c5b903ea94d9a2a448db63f0a296265aee976d17abf734758/websockets-13.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5df891c86fe68b2c38da55b7aea7095beca105933c697d719f3f45f4220a5e0e", size = 157507 }, + { url = "https://files.pythonhosted.org/packages/f8/4e/ffa2f1aad2da67e483fb7bad6c69f80c786f4e85d1942a39d7b275b084ed/websockets-13.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac2d146ff30d9dd2fcf917e5d147db037a5c573f0446c564f16f1f94cf87462", size = 157881 }, + { url = "https://files.pythonhosted.org/packages/c0/85/0cbfe7b0e0dd3d885cd87b0523c6690ae7369feaf3aab5a23e95bdb4fefa/websockets-13.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b8ac5b46fd798bbbf2ac6620e0437c36a202b08e1f827832c4bf050da081b501", size = 158187 }, + { url = "https://files.pythonhosted.org/packages/39/29/d9df0a1daedebefaeea88fb8071539604df09fd0f1bfb73bf58333aa3eb6/websockets-13.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46af561eba6f9b0848b2c9d2427086cabadf14e0abdd9fde9d72d447df268418", size = 157626 }, + { url = "https://files.pythonhosted.org/packages/7d/9a/f88e186059f6b89f8bb08461d9fda7a26940b7b8897c7d7f02aead40b7e4/websockets-13.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b5a06d7f60bc2fc378a333978470dfc4e1415ee52f5f0fce4f7853eb10c1e9df", size = 157575 }, + { url = "https://files.pythonhosted.org/packages/cf/e4/ecdb8352ebab2e44c10b9d6f50008f95e30bb0a7ef0e6b66cb475d539d74/websockets-13.0.1-cp311-cp311-win32.whl", hash = "sha256:556e70e4f69be1082e6ef26dcb70efcd08d1850f5d6c5f4f2bcb4e397e68f01f", size = 151779 }, + { url = "https://files.pythonhosted.org/packages/12/40/46967d00640e6c3231b73d310617927a11c91bcc044dd5a0860a3c457c33/websockets-13.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:67494e95d6565bf395476e9d040037ff69c8b3fa356a886b21d8422ad86ae075", size = 152206 }, + { url = "https://files.pythonhosted.org/packages/4e/51/23ed2d239f1c3087c1431d41cfd159865df0bc35bb0c89973e3b6a0fff9b/websockets-13.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f9c9e258e3d5efe199ec23903f5da0eeaad58cf6fccb3547b74fd4750e5ac47a", size = 150953 }, + { url = "https://files.pythonhosted.org/packages/57/8d/814a7ef62b916b0f39108ad2e4d9b4cb0f8c640f8c30202fb63041598ada/websockets-13.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6b41a1b3b561f1cba8321fb32987552a024a8f67f0d05f06fcf29f0090a1b956", size = 148610 }, + { url = "https://files.pythonhosted.org/packages/ad/8b/a378d21124011737e0e490a8a6ef778914b03e50c8d938de2f2170a20dbd/websockets-13.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f73e676a46b0fe9426612ce8caeca54c9073191a77c3e9d5c94697aef99296af", size = 148849 }, + { url = "https://files.pythonhosted.org/packages/46/d2/814a61226af313c1bc289cfe3a10f87bf426b6f2d9df0f927c47afab7612/websockets-13.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f613289f4a94142f914aafad6c6c87903de78eae1e140fa769a7385fb232fdf", size = 158772 }, + { url = "https://files.pythonhosted.org/packages/a1/7e/5987299eb7e131216c9027b05a65f149cbc2bde7c582e694d9eed6ec3d40/websockets-13.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f52504023b1480d458adf496dc1c9e9811df4ba4752f0bc1f89ae92f4f07d0c", size = 157724 }, + { url = "https://files.pythonhosted.org/packages/94/6e/eaf95894042ba8a05a125fe8bcf9ee3572fef6edbcbf49478f4991c027cc/websockets-13.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:139add0f98206cb74109faf3611b7783ceafc928529c62b389917a037d4cfdf4", size = 158152 }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a1315d569cc2dadaafda74a9cea16ab5d68142525937f1994442d969b306/websockets-13.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47236c13be337ef36546004ce8c5580f4b1150d9538b27bf8a5ad8edf23ccfab", size = 158442 }, + { url = "https://files.pythonhosted.org/packages/90/9b/59866695cfd05e785c90932fef3dae4682eb4e06e7076b7c53478f25faad/websockets-13.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c44ca9ade59b2e376612df34e837013e2b273e6c92d7ed6636d0556b6f4db93d", size = 157823 }, + { url = "https://files.pythonhosted.org/packages/9b/47/20af68a313b6453d2d094ccc497b7232e8475175d234e3e5bef5088521e5/websockets-13.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9bbc525f4be3e51b89b2a700f5746c2a6907d2e2ef4513a8daafc98198b92237", size = 157818 }, + { url = "https://files.pythonhosted.org/packages/f8/bb/60aaedc80e388e978617dda1ff38788780c6b0f6e462b85368cb934131a5/websockets-13.0.1-cp312-cp312-win32.whl", hash = "sha256:3624fd8664f2577cf8de996db3250662e259bfbc870dd8ebdcf5d7c6ac0b5185", size = 151785 }, + { url = "https://files.pythonhosted.org/packages/16/2e/e47692f569e1be2e66c1dbc5e85ea4d2cc93b80027fbafa28ae8b0dee52c/websockets-13.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0513c727fb8adffa6d9bf4a4463b2bade0186cbd8c3604ae5540fae18a90cb99", size = 152214 }, + { url = "https://files.pythonhosted.org/packages/46/37/d8ef4b68684d1fa368a5c64be466db07fc58b68163bc2496db2d4cc208ff/websockets-13.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1ee4cc030a4bdab482a37462dbf3ffb7e09334d01dd37d1063be1136a0d825fa", size = 150962 }, + { url = "https://files.pythonhosted.org/packages/95/49/78aeb3af08ec9887a9065e85cef9d7e199d6c6261fcd39eec087f3a62328/websockets-13.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbb0b697cc0655719522406c059eae233abaa3243821cfdfab1215d02ac10231", size = 148621 }, + { url = "https://files.pythonhosted.org/packages/31/0d/dc9b7cec8deaee452092a631ccda894bd7098859f71dd7639b4b5b9c615c/websockets-13.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:acbebec8cb3d4df6e2488fbf34702cbc37fc39ac7abf9449392cefb3305562e9", size = 148853 }, + { url = "https://files.pythonhosted.org/packages/16/bf/734cbd815d7bc94cffe35c934f4e08b619bf3b47df1c6c7af21c1d35bcfe/websockets-13.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63848cdb6fcc0bf09d4a155464c46c64ffdb5807ede4fb251da2c2692559ce75", size = 158741 }, + { url = "https://files.pythonhosted.org/packages/af/9b/756f89b12fee8931785531a314e6f087b21774a7f8c60878e597c684f91b/websockets-13.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:872afa52a9f4c414d6955c365b6588bc4401272c629ff8321a55f44e3f62b553", size = 157690 }, + { url = "https://files.pythonhosted.org/packages/d3/37/31f97132d2262e666b797e250879ca833eab55115f88043b3952a2840eb8/websockets-13.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05e70fec7c54aad4d71eae8e8cab50525e899791fc389ec6f77b95312e4e9920", size = 158132 }, + { url = "https://files.pythonhosted.org/packages/41/ce/59c8d44e148c002fec506a9527504fb4281676e2e75c2ee5a58180f1b99a/websockets-13.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e82db3756ccb66266504f5a3de05ac6b32f287faacff72462612120074103329", size = 158490 }, + { url = "https://files.pythonhosted.org/packages/1a/74/5b31ce0f318b902c0d70c031f8e1228ba1a4d95a46b2a24a2a5ac17f9cf0/websockets-13.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4e85f46ce287f5c52438bb3703d86162263afccf034a5ef13dbe4318e98d86e7", size = 157879 }, + { url = "https://files.pythonhosted.org/packages/0d/a7/6eac4f04177644bbc98deb98d11770cc7fbc216f6f67ab187c150540fd52/websockets-13.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3fea72e4e6edb983908f0db373ae0732b275628901d909c382aae3b592589f2", size = 157873 }, + { url = "https://files.pythonhosted.org/packages/72/f6/b8b30a3b134dfdb4ccd1694befa48fddd43783957c988a1dab175732af33/websockets-13.0.1-cp313-cp313-win32.whl", hash = "sha256:254ecf35572fca01a9f789a1d0f543898e222f7b69ecd7d5381d8d8047627bdb", size = 151782 }, + { url = "https://files.pythonhosted.org/packages/3e/88/d94ccc006c69583168aa9dd73b3f1885c8931f2c676f4bdd8cbfae91c7b6/websockets-13.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca48914cdd9f2ccd94deab5bcb5ac98025a5ddce98881e5cce762854a5de330b", size = 152212 }, + { url = "https://files.pythonhosted.org/packages/fd/bd/d34c4b7918453506d2149208b175368738148ffc4ba256d7fd8708956732/websockets-13.0.1-py3-none-any.whl", hash = "sha256:b80f0c51681c517604152eb6a572f5a9378f877763231fddb883ba2f968e8817", size = 145262 }, +] + [[package]] name = "werkzeug" version = "3.0.4" @@ -1593,6 +1763,8 @@ dependencies = [ { name = "gunicorn" }, { name = "msgspec" }, { name = "requests" }, + { name = "uvicorn", extra = ["standard"] }, + { name = "uvicorn-worker" }, { name = "whitenoise" }, ] @@ -1649,6 +1821,8 @@ requires-dist = [ { name = "sqlite-utils", marker = "extra == 'dev'", specifier = "==3.*" }, { name = "time-machine", marker = "extra == 'test'", specifier = "==2.*" }, { name = "types-requests", marker = "extra == 'dev'", specifier = "==2.*" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.30.*" }, + { name = "uvicorn-worker", specifier = "==0.2.*" }, { name = "whitenoise", specifier = "==6.*" }, { name = "zakuchess" }, ] From 24769a58ac89f549e2ca77e3849769fde2877bc8 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 6 Sep 2024 17:14:35 +0100 Subject: [PATCH 08/42] [lichess] WIP: stop using Berserk, add integration to more endpoints We can now create correspondence games on Lichess, and display the ongoing games. [bugfix] Our assets are now correctly served straight from the "static/" folder of each app in development mode, even though we stopped using "runserver" - thanks to WhiteNoise. Misc other bugfixes here and there. --- Dockerfile | 4 +- Makefile | 2 +- pyproject.toml | 22 ++- .../{daily_chess.py => daily_chess_pages.py} | 0 src/apps/daily_challenge/views.py | 2 +- .../components/pages/lichess.py | 68 -------- .../components/pages/lichess_pages.py | 162 ++++++++++++++++++ .../lichess_bridge/components/svg_icons.py | 7 + src/apps/lichess_bridge/forms.py | 9 + src/apps/lichess_bridge/lichess_api.py | 102 +++++++++-- src/apps/lichess_bridge/models.py | 20 ++- src/apps/lichess_bridge/tests/test_views.py | 55 ++++-- src/apps/lichess_bridge/urls.py | 7 + src/apps/lichess_bridge/views.py | 66 +++++-- src/apps/lichess_bridge/views_decorators.py | 72 ++++++++ src/project/asgi.py | 2 +- src/project/settings/_base.py | 3 + src/project/settings/development.py | 8 + src/project/settings/production.py | 6 - src/project/urls.py | 8 - uv.lock | 36 ++-- 21 files changed, 516 insertions(+), 145 deletions(-) rename src/apps/daily_challenge/components/pages/{daily_chess.py => daily_chess_pages.py} (100%) delete mode 100644 src/apps/lichess_bridge/components/pages/lichess.py create mode 100644 src/apps/lichess_bridge/components/pages/lichess_pages.py create mode 100644 src/apps/lichess_bridge/forms.py create mode 100644 src/apps/lichess_bridge/views_decorators.py diff --git a/Dockerfile b/Dockerfile index 8ddfb81..549b5ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,8 +115,8 @@ FROM python:3.11-slim-bookworm AS assets_download # By having a separate build stage for downloading assets, we can cache them # as long as the `download_assets.py` doesn't change. -# should preferably be the same as in `poetry.lock`: -ENV PYTHON_HTTPX_VERSION=0.26.0 +# should preferably be the same as in `uv.lock`: +ENV PYTHON_HTTPX_VERSION=0.27.2 RUN pip install -U pip httpx==${PYTHON_HTTPX_VERSION} diff --git a/Makefile b/Makefile index 893e33b..e45f651 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ backend/watch: address ?= localhost backend/watch: port ?= 8000 backend/watch: dotenv_file ?= .env.local backend/watch: ## Start Django via Uvicorn, in "watch" mode - @@DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ + @DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ ${UV} run uvicorn \ --reload --reload-dir src/ \ --host ${address} --port ${port} \ diff --git a/pyproject.toml b/pyproject.toml index 636ea17..74424a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies= [ "msgspec==0.18.*", "zakuchess", "authlib==1.*", - "berserk>=0.13.2", + "httpx==0.27.*", ] @@ -41,16 +41,20 @@ dev = [ "ipython==8.*", "types-requests==2.*", "django-extensions==3.*", - # (httpx is only used in "scripts/download_assets.py" for parallel downloads) - "httpx==0.26.*", "sqlite-utils==3.*", + # N.B. As it turns out that Lichess' "Berserk" package misses some features we need, + # such as the creation of correspondence Seeks... + # And as we had to wrap evey call in an `sync_to_async` anyway, which was not great... + # We only use for its `types` module at the momemt. + "berserk==0.13.*", ] test = [ - "pytest==7.*", + "pytest==8.3.*", "pytest-django==4.*", "pytest-cov==4.*", "time-machine==2.*", "pytest-blockage==0.2.*", + "pytest-asyncio==0.24.*", ] load-testing = [ "locust==2.*", @@ -127,9 +131,17 @@ testpaths = [ "src/project/tests/", ] python_files = ["test_*.py"] +# pytest-django settings: +# https://pytest-django.readthedocs.io/ addopts = "--reuse-db" DJANGO_SETTINGS_MODULE = "project.settings.test" -blockage = true # https://github.com/rob-b/pytest-blockage +# pytest-asyncio settings: +# https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html +asyncio_default_fixture_loop_scope = "function" +asyncio_mode = "auto" +# pytest-blockage settings: +# https://github.com/rob-b/pytest-blockage +blockage = true [tool.coverage.run] # @link https://coverage.readthedocs.io/en/latest/excluding.html diff --git a/src/apps/daily_challenge/components/pages/daily_chess.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py similarity index 100% rename from src/apps/daily_challenge/components/pages/daily_chess.py rename to src/apps/daily_challenge/components/pages/daily_chess_pages.py diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index c64517c..f776266 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -25,7 +25,7 @@ from .components.misc_ui.help_modal import help_modal from .components.misc_ui.stats_modal import stats_modal from .components.misc_ui.user_prefs_modal import user_prefs_modal -from .components.pages.daily_chess import ( +from .components.pages.daily_chess_pages import ( daily_challenge_moving_parts_fragment, daily_challenge_page, ) diff --git a/src/apps/lichess_bridge/components/pages/lichess.py b/src/apps/lichess_bridge/components/pages/lichess.py deleted file mode 100644 index d686ce8..0000000 --- a/src/apps/lichess_bridge/components/pages/lichess.py +++ /dev/null @@ -1,68 +0,0 @@ -from typing import TYPE_CHECKING - -from django.urls import reverse -from dominate.tags import button, div, form, p, section - -from apps.webui.components import common_styles -from apps.webui.components.forms_common import csrf_hidden_input -from apps.webui.components.layout import page - -from ... import lichess_api -from ..misc_ui import detach_lichess_account_form -from ..svg_icons import ICON_SVG_LOG_IN - -if TYPE_CHECKING: - from django.http import HttpRequest - - from ...models import LichessAccessToken - - -def lichess_no_account_linked_page( - *, - request: "HttpRequest", -) -> str: - return page( - section( - form( - csrf_hidden_input(request), - p("Click here to log in to Lichess"), - button( - "Log in via Lichess", - " ", - ICON_SVG_LOG_IN, - type="submit", - cls=common_styles.BUTTON_CLASSES, - ), - action=reverse("lichess_bridge:oauth2_start_flow"), - method="POST", - ), - cls="text-slate-50", - ), - request=request, - title="Lichess - no account linked", - ) - - -async def lichess_account_linked_homepage( - *, - request: "HttpRequest", - access_token: "LichessAccessToken", -) -> str: - me = await lichess_api.get_lichess_my_account(access_token) - - return page( - div( - section( - f'Hello {me["username"]}!', - cls="text-slate-50", - ), - div( - detach_lichess_account_form(request), - cls="mt-4", - ), - cls="w-full mx-auto bg-slate-900 min-h-48 " - "md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", - ), - request=request, - title="Lichess - account linked", - ) diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py new file mode 100644 index 0000000..1be606c --- /dev/null +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -0,0 +1,162 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import ( + a, + button, + div, + fieldset, + form, + input_, + label, + legend, + li, + p, + section, + ul, +) + +from apps.webui.components import common_styles +from apps.webui.components.forms_common import csrf_hidden_input +from apps.webui.components.layout import page + +from ... import lichess_api +from ...models import LichessCorrespondenceGameDaysChoice +from ..misc_ui import detach_lichess_account_form +from ..svg_icons import ICON_SVG_CREATE, ICON_SVG_LOG_IN + +if TYPE_CHECKING: + from django.http import HttpRequest + + from ...models import LichessAccessToken + + +def lichess_no_account_linked_page( + *, + request: "HttpRequest", +) -> str: + return page( + section( + form( + csrf_hidden_input(request), + p("Click here to log in to Lichess"), + button( + "Log in via Lichess", + " ", + ICON_SVG_LOG_IN, + type="submit", + cls=common_styles.BUTTON_CLASSES, + ), + action=reverse("lichess_bridge:oauth2_start_flow"), + method="POST", + ), + cls="text-slate-50", + ), + request=request, + title="Lichess - no account linked", + ) + + +async def lichess_account_linked_homepage( + *, + request: "HttpRequest", + access_token: "LichessAccessToken", +) -> str: + me = await lichess_api.get_my_account(access_token=access_token) + ongoing_games = await lichess_api.get_my_ongoing_games(access_token=access_token) + + return page( + div( + section( + f"Hello {me.username}!", + ul( + *[li(game.gameId) for game in ongoing_games], + ), + p( + a( + "Create a new game", + href=reverse("lichess_bridge:create_correspondence_game"), + cls=common_styles.BUTTON_CLASSES, + ), + ), + cls="text-slate-50", + ), + div( + detach_lichess_account_form(request), + cls="mt-4", + ), + cls="w-full mx-auto bg-slate-900 min-h-48 " + "md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", + ), + request=request, + title="Lichess - account linked", + ) + + +def lichess_correspondence_game_creation_page( + request: "HttpRequest", form_errors: dict +) -> str: + return page( + div( + section( + form( + csrf_hidden_input(request), + div( + fieldset( + legend("Days per turn."), + ( + p(form_errors["days_per_turn"], cls="text-red-600 ") + if "days_per_turn" in form_errors + else "" + ), + div( + *[ + div( + input_( + id=f"days-per-turn-{value}", + type="radio", + name="days_per_turn", + value=value, + checked=( + value + == LichessCorrespondenceGameDaysChoice.THREE_DAYS.value # type: ignore[attr-defined] + ), + ), + label( + display, html_for=f"days-per-turn-{value}" + ), + ) + for value, display in LichessCorrespondenceGameDaysChoice.choices + ], + cls="flex gap-3", + ), + cls="block text-sm font-bold mb-2", + ), + input_( + id="days-per-turn", + cls="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline", + ), + cls="mb-4", + ), + button( + "Create", + " ", + ICON_SVG_CREATE, + type="submit", + cls=common_styles.BUTTON_CLASSES, + ), + action=reverse("lichess_bridge:create_correspondence_game"), + method="POST", + ), + cls="text-slate-50", + ), + div( + detach_lichess_account_form(request), + cls="mt-4", + ), + cls="w-full mx-auto bg-slate-900 min-h-48 " + "md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", + ), + request=request, + title="Lichess - new correspondence game", + ) diff --git a/src/apps/lichess_bridge/components/svg_icons.py b/src/apps/lichess_bridge/components/svg_icons.py index 2f0b679..756142e 100644 --- a/src/apps/lichess_bridge/components/svg_icons.py +++ b/src/apps/lichess_bridge/components/svg_icons.py @@ -13,3 +13,10 @@ """ ) + +# https://heroicons.com/, icon `plus-circle` +ICON_SVG_CREATE = raw( + r""" + + """ +) diff --git a/src/apps/lichess_bridge/forms.py b/src/apps/lichess_bridge/forms.py new file mode 100644 index 0000000..67cf9e5 --- /dev/null +++ b/src/apps/lichess_bridge/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from .models import LichessCorrespondenceGameDaysChoice + + +class LichessCorrespondenceGameCreationForm(forms.Form): + days_per_turn = forms.ChoiceField( + choices=LichessCorrespondenceGameDaysChoice.choices + ) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 35e0e59..77e7962 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -1,30 +1,106 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal -import berserk -from asgiref.sync import sync_to_async +import httpx +import msgspec +from django.conf import settings from .models import LICHESS_ACCESS_TOKEN_PREFIX if TYPE_CHECKING: - from .models import LichessAccessToken + from .models import LichessAccessToken, LichessGameSeekId + + +class AccountInformation( + msgspec.Struct, +): + """Information about an account, as returned by the Lichess API.""" + + # N.B. There are many more fields than this - but we only use these at the moment + + id: str # e.g. "philippe" + username: str # e.g. "Philippe" + url: str # e.g. "https://lichess.org/@/dunsap" + + +class OpponentData(msgspec.Struct): + """Information about an opponent, as returned by the Lichess API.""" + + id: str # e.g. "philippe" + rating: int # e.g. 1790 + username: str # e.g. "Philippe" + + +class OngoingGameData(msgspec.Struct): + """Information about an ongoing game, as returned by the Lichess API.""" + + gameId: str + fullId: str + color: Literal["white", "black"] + fen: str + hasMoved: bool + isMyTurn: bool + lastMove: str # e.g. "b8c6" + opponent: OpponentData + perf: Literal["correspondence"] # TODO: other values? + rated: bool + secondsLeft: int + source: Literal["lobby", "friend"] # TODO: other values? + speed: Literal["correspondence"] # TODO: other values? + variant: dict[str, str] def is_lichess_api_access_token_valid(token: str) -> bool: return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10 -@sync_to_async(thread_sensitive=False) -def get_lichess_my_account( +async def get_my_account( + *, access_token: "LichessAccessToken", -) -> berserk.types.AccountInformation: - return _get_lichess_api_client(access_token).account.get() +) -> AccountInformation: + async with _create_lichess_api_client(access_token) as client: + # https://lichess.org/api#tag/Account/operation/accountMe + response = await client.get("/api/account") + return msgspec.json.decode(response.content, type=AccountInformation) + + +async def get_my_ongoing_games( + *, + access_token: "LichessAccessToken", + count: int = 5, +) -> list[OngoingGameData]: + async with _create_lichess_api_client(access_token) as client: + # https://lichess.org/api#tag/Games/operation/apiAccountPlaying + response = await client.get("/api/account/playing", params={"nb": count}) + + class ResponseDataWrapper(msgspec.Struct): + nowPlaying: list[OngoingGameData] + + return msgspec.json.decode( + response.content, type=ResponseDataWrapper + ).nowPlaying -def _get_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: - return _create_lichess_api_client(access_token) +async def create_correspondence_game( + *, access_token: "LichessAccessToken", days_per_turn: int +) -> "LichessGameSeekId": + async with _create_lichess_api_client(access_token) as client: + # https://lichess.org/api#tag/Board/operation/apiBoardSeek + # TODO: give more customisation options to the user + response = await client.post( + "/api/board/seek", + json={ + "rated": False, + "days": days_per_turn, + "variant": "standard", + "color": "random", + }, + ) + return str(response.json()["id"]) # This is the function we'll mock during tests: -def _create_lichess_api_client(access_token: "LichessAccessToken") -> berserk.Client: - session = berserk.TokenSession(access_token) - return berserk.Client(session=session) +def _create_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=settings.LICHESS_HOST, + headers={"Authorization": f"Bearer {access_token}"}, + ) diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 833c3b2..8e79711 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -1,8 +1,22 @@ from typing import TypeAlias -LichessAccessToken: TypeAlias = str +from django.db import models -# > By convention tokens have a recognizable prefix, but do not rely on this. -# Let's still rely on this for now ^^ +LichessAccessToken: TypeAlias = str # e.g. "lio_6EeGimHMalSVH9qMcfUc2JJ3xdBPlqrL" +LichessGameSeekId: TypeAlias = str # e.g. "oIsGhJaf" + +# > By convention tokens have a recognizable prefix, but do not rely on this. +# Well... Let's still rely on this for now ^^ # TODO: remove this, as it may break one day? LICHESS_ACCESS_TOKEN_PREFIX = "lio_" + + +class LichessCorrespondenceGameDaysChoice(models.IntegerChoices): + # https://lichess.org/api#tag/Board/operation/apiBoardSeek + ONE_DAY = (1, "1 days") + TWO_DAYS = (2, "2 days") + THREE_DAYS = (3, "3 days") + FIVE_DAYS = (5, "5 days") + SEVEN_DAYS = (7, "7 days") + TEN_DAYS = (10, "10 days") + FOURTEEN_DAYS = (14, "14 days") diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 16c56be..cee6a91 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -1,9 +1,10 @@ +import json from http import HTTPStatus -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from unittest import mock if TYPE_CHECKING: - from django.test import Client as DjangoClient + from django.test import AsyncClient as DjangoAsyncClient, Client as DjangoClient def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): @@ -17,23 +18,53 @@ def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): assert "Log out from Lichess" not in response_html -@mock.patch("apps.lichess_bridge.lichess_api._create_lichess_api_client") -def test_lichess_homepage_with_access_token_smoke_test( - create_lichess_api_client_mock: mock.MagicMock, client: "DjangoClient" +async def test_lichess_homepage_with_access_token_smoke_test( + async_client: "DjangoAsyncClient", ): """Just a quick smoke test for now""" - create_lichess_api_client_mock.return_value.account.get.return_value = { - "username": "ChessChampion" - } + access_token = "lio_123456789" + async_client.cookies["lichess.access_token"] = access_token + with mock.patch( + "apps.lichess_bridge.lichess_api._create_lichess_api_client", + ) as create_lichess_api_client_mock: + + class HttpClientMock: + class HttpClientResponseMock: + def __init__(self, path): + self.path = path + + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case "/api/account/playing": + result = {"nowPlaying": []} + return json.dumps(result) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith("/api/") + return self.HttpClientResponseMock(path) + + create_lichess_api_client_mock.return_value.__aenter__.return_value = ( + HttpClientMock() + ) + + response = await async_client.get("/lichess/") + + assert create_lichess_api_client_mock.call_count == 2 - client.cookies["lichess.access_token"] = "lio_123456789" - response = client.get("/lichess/") assert response.status_code == HTTPStatus.OK response_html = response.content.decode("utf-8") assert "Log in via Lichess" not in response_html assert "Log out from Lichess" in response_html assert "ChessChampion" in response_html - - create_lichess_api_client_mock.assert_called_once_with("lio_123456789") diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index 1e8f324..1f02eb4 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -6,6 +6,13 @@ urlpatterns = [ path("", views.lichess_home, name="homepage"), + # Game management Views: + path( + "games/new/", + views.lichess_correspondence_game_create, + name="create_correspondence_game", + ), + # OAuth2 Views: path( "oauth2/start-flow/", views.lichess_redirect_to_oauth2_flow_starting_url, diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 99f58d8..f3bca75 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -2,35 +2,45 @@ from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import redirect -from django.views.decorators.http import require_GET, require_POST +from django.views.decorators.http import ( + require_http_methods, + require_POST, + require_safe, +) -from . import cookie_helpers +from . import cookie_helpers, lichess_api from .authentication import ( LichessTokenRetrievalProcessContext, check_csrf_state_from_oauth2_callback, fetch_lichess_token_from_oauth2_callback, get_lichess_token_retrieval_via_oauth2_process_starting_url, ) -from .components.pages.lichess import ( - lichess_account_linked_homepage, - lichess_no_account_linked_page, +from .components.pages import lichess_pages as lichess_pages +from .forms import LichessCorrespondenceGameCreationForm +from .views_decorators import ( + redirect_if_no_lichess_access_token, + with_lichess_access_token, ) if TYPE_CHECKING: from django.http import HttpRequest + from .models import LichessAccessToken + +# TODO: use Django message framework for everything that happens outside of the chess +# board, so we can notify users of what's going on +# (we don't use HTMX for these steps, which will make the display of such messages easier) -@require_GET -async def lichess_home(request: "HttpRequest") -> HttpResponse: - # Do we have a Lichess API token for this user? - lichess_access_token = cookie_helpers.get_lichess_api_access_token_from_request( - request - ) +@require_safe +@with_lichess_access_token +async def lichess_home( + request: "HttpRequest", lichess_access_token: "LichessAccessToken | None" +) -> HttpResponse: if not lichess_access_token: - page_content = lichess_no_account_linked_page(request=request) + page_content = lichess_pages.lichess_no_account_linked_page(request=request) else: - page_content = await lichess_account_linked_homepage( + page_content = await lichess_pages.lichess_account_linked_homepage( request=request, access_token=lichess_access_token, ) @@ -38,6 +48,33 @@ async def lichess_home(request: "HttpRequest") -> HttpResponse: return HttpResponse(page_content) +@require_http_methods(["GET", "POST"]) +@with_lichess_access_token +@redirect_if_no_lichess_access_token +async def lichess_correspondence_game_create( + request: "HttpRequest", lichess_access_token: "LichessAccessToken" +) -> HttpResponse: + form_errors = {} + if request.method == "POST": + form = LichessCorrespondenceGameCreationForm(request.POST) + if not form.is_valid(): + form_errors = form.errors + else: + # N.B. This function returns a Lichess "Seek ID", but we don't use it atm. + await lichess_api.create_correspondence_game( + access_token=lichess_access_token, + days_per_turn=form.cleaned_data["days_per_turn"], + ) + + return redirect("lichess_bridge:homepage") + + return HttpResponse( + lichess_pages.lichess_correspondence_game_creation_page( + request=request, form_errors=form_errors + ) + ) + + @require_POST def lichess_redirect_to_oauth2_flow_starting_url( request: "HttpRequest", @@ -60,7 +97,7 @@ def lichess_redirect_to_oauth2_flow_starting_url( return response -@require_GET +@require_safe def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpResponse: # Retrieve a context from the HTTP-only cookie we created above: lichess_oauth2_process_context = ( @@ -72,6 +109,7 @@ def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpRespons # We have to check the "CSRF state": # ( https://stack-auth.com/blog/oauth-from-first-principles#attack-4 ) + # TODO: add a test that checks that it does fail if the state doesn't match check_csrf_state_from_oauth2_callback( request=request, context=lichess_oauth2_process_context ) diff --git a/src/apps/lichess_bridge/views_decorators.py b/src/apps/lichess_bridge/views_decorators.py new file mode 100644 index 0000000..7e6885a --- /dev/null +++ b/src/apps/lichess_bridge/views_decorators.py @@ -0,0 +1,72 @@ +import functools +from typing import TYPE_CHECKING + +from asgiref.sync import iscoroutinefunction +from django.shortcuts import redirect + +from . import cookie_helpers + +if TYPE_CHECKING: + from django.http import HttpRequest + + from .models import LichessAccessToken + + +def with_lichess_access_token(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(request: "HttpRequest", *args, **kwargs): + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) + return await func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + else: + + @functools.wraps(func) + def wrapper(request: "HttpRequest", *args, **kwargs): + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) + return func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + return wrapper + + +def redirect_if_no_lichess_access_token(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper( + request: "HttpRequest", + *args, + lichess_access_token: "LichessAccessToken | None", + **kwargs, + ): + if not lichess_access_token: + return redirect("lichess_bridge:homepage") + return await func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + else: + + @functools.wraps(func) + def wrapper( + request: "HttpRequest", + *args, + lichess_access_token: "LichessAccessToken | None", + **kwargs, + ): + if not lichess_access_token: + return redirect("lichess_bridge:homepage") + return func( + request, *args, lichess_access_token=lichess_access_token, **kwargs + ) + + return wrapper diff --git a/src/project/asgi.py b/src/project/asgi.py index 83ce383..267d813 100644 --- a/src/project/asgi.py +++ b/src/project/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings.production") application = get_asgi_application() diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index f3349a5..15b6ede 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -55,6 +55,9 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + # > The WhiteNoise middleware should be placed directly after the + # > Django SecurityMiddleware and before all other middleware + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", diff --git a/src/project/settings/development.py b/src/project/settings/development.py index dad00fc..72fe4f6 100644 --- a/src/project/settings/development.py +++ b/src/project/settings/development.py @@ -6,6 +6,14 @@ DEBUG = True +INSTALLED_APPS.insert( + # Make sure `runserver` doesn't try to serve static assets, + # even without the `--no-static` option: + # (https://whitenoise.readthedocs.io/en/stable/django.html#using-whitenoise-in-development) + INSTALLED_APPS.index("django.contrib.staticfiles"), + "whitenoise.runserver_nostatic", +) + INSTALLED_APPS += [ "django_extensions", ] diff --git a/src/project/settings/production.py b/src/project/settings/production.py index 0ea6ef9..8cb7143 100644 --- a/src/project/settings/production.py +++ b/src/project/settings/production.py @@ -10,12 +10,6 @@ # Static assets served by Whitenoise on production # @link http://whitenoise.evans.io/en/stable/ -# > The WhiteNoise middleware should be placed directly after the -# > Django SecurityMiddleware and before all other middleware -MIDDLEWARE.insert( - MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1, - "whitenoise.middleware.WhiteNoiseMiddleware", -) STORAGES["staticfiles"] = { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", } diff --git a/src/project/urls.py b/src/project/urls.py index ff0d002..7dc8ce7 100644 --- a/src/project/urls.py +++ b/src/project/urls.py @@ -4,7 +4,6 @@ https://docs.djangoproject.com/en/5.1/topics/http/urls/ """ -from django.conf import settings from django.contrib import admin from django.urls import include, path, register_converter @@ -18,10 +17,3 @@ path("-/", include("django_alive.urls")), path("admin/", admin.site.urls), ] - -if settings.DEBUG: - # @link https://docs.djangoproject.com/en/5.1/howto/static-files/ - - from django.conf.urls.static import static - - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/uv.lock b/uv.lock index 52a4a10..bd40e5b 100644 --- a/uv.lock +++ b/uv.lock @@ -713,7 +713,7 @@ wheels = [ [[package]] name = "httpx" -version = "0.26.0" +version = "0.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -722,9 +722,9 @@ dependencies = [ { name = "idna" }, { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/26/2dc654950920f499bd062a211071925533f821ccdca04fa0c2fd914d5d06/httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf", size = 125671 } +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/9b/4937d841aee9c2c8102d9a4eeb800c7dad25386caabb4a1bf5010df81a57/httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd", size = 75862 }, + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, ] [[package]] @@ -1104,7 +1104,7 @@ wheels = [ [[package]] name = "pytest" -version = "7.4.4" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1112,9 +1112,21 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116 } +sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287 }, + { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, ] [[package]] @@ -1751,7 +1763,6 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "authlib" }, - { name = "berserk" }, { name = "chess" }, { name = "dj-database-url" }, { name = "django" }, @@ -1761,6 +1772,7 @@ dependencies = [ { name = "django-import-export" }, { name = "dominate" }, { name = "gunicorn" }, + { name = "httpx" }, { name = "msgspec" }, { name = "requests" }, { name = "uvicorn", extra = ["standard"] }, @@ -1770,8 +1782,8 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "berserk" }, { name = "django-extensions" }, - { name = "httpx" }, { name = "ipython" }, { name = "mypy" }, { name = "pre-commit" }, @@ -1785,6 +1797,7 @@ load-testing = [ ] test = [ { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-blockage" }, { name = "pytest-cov" }, { name = "pytest-django" }, @@ -1794,7 +1807,7 @@ test = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = "==1.*" }, - { name = "berserk", specifier = ">=0.13.2" }, + { name = "berserk", marker = "extra == 'dev'", specifier = "==0.13.*" }, { name = "chess", specifier = "==1.*" }, { name = "dj-database-url", specifier = "==2.*" }, { name = "django", specifier = "==5.1.*" }, @@ -1805,13 +1818,14 @@ requires-dist = [ { name = "django-import-export", specifier = "==4.*" }, { name = "dominate", specifier = "==2.*" }, { name = "gunicorn", specifier = "==22.*" }, - { name = "httpx", marker = "extra == 'dev'", specifier = "==0.26.*" }, + { name = "httpx", specifier = "==0.27.*" }, { name = "ipython", marker = "extra == 'dev'", specifier = "==8.*" }, { name = "locust", marker = "extra == 'load-testing'", specifier = "==2.*" }, { name = "msgspec", specifier = "==0.18.*" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.*" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.*" }, - { name = "pytest", marker = "extra == 'test'", specifier = "==7.*" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==8.3.*" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==0.24.*" }, { name = "pytest-blockage", marker = "extra == 'test'", specifier = "==0.2.*" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==4.*" }, { name = "pytest-django", marker = "extra == 'test'", specifier = "==4.*" }, From 39df04b52dbc437cd971aa102672f624c8038dd2 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Mon, 9 Sep 2024 12:25:49 +0100 Subject: [PATCH 09/42] [lichess] We can now display (but not interact with) a Lichess game! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There's still a hell lot of work to do 😅, but hey that's a first milestone reached :-) --- .../_calculate_fen_before_move.py | 2 +- .../_calculate_piece_available_targets.py | 2 +- .../chess/business_logic/_do_chess_move.py | 2 +- .../chess/{helpers.py => chess_helpers.py} | 9 +- src/apps/chess/components/chess_board.py | 4 +- src/apps/chess/components/chess_helpers.py | 4 +- src/apps/chess/presenters.py | 19 +- src/apps/chess/types.py | 1 + .../_compute_fields_before_bot_first_move.py | 2 +- .../business_logic/_get_speech_bubble.py | 2 +- .../_move_daily_challenge_piece.py | 2 +- ..._daily_challenge_teams_and_pieces_roles.py | 17 +- .../business_logic/_undo_last_move.py | 2 +- .../components/misc_ui/status_bar.py | 2 +- src/apps/daily_challenge/presenters.py | 2 +- src/apps/daily_challenge/tests/_helpers.py | 2 +- src/apps/daily_challenge/views.py | 6 +- .../components/lichess_account.py | 41 ++++ src/apps/lichess_bridge/components/misc_ui.py | 39 ++-- .../components/ongoing_games.py | 61 +++++ .../components/pages/lichess_pages.py | 67 ++++-- src/apps/lichess_bridge/lichess_api.py | 142 ++++++------ src/apps/lichess_bridge/models.py | 153 ++++++++++++- src/apps/lichess_bridge/presenters.py | 212 ++++++++++++++++++ src/apps/lichess_bridge/tests/test_views.py | 4 +- src/apps/lichess_bridge/urls.py | 9 +- src/apps/lichess_bridge/views.py | 67 +++++- 27 files changed, 715 insertions(+), 160 deletions(-) rename src/apps/chess/{helpers.py => chess_helpers.py} (96%) create mode 100644 src/apps/lichess_bridge/components/lichess_account.py create mode 100644 src/apps/lichess_bridge/components/ongoing_games.py create mode 100644 src/apps/lichess_bridge/presenters.py diff --git a/src/apps/chess/business_logic/_calculate_fen_before_move.py b/src/apps/chess/business_logic/_calculate_fen_before_move.py index eeb3706..69160b6 100644 --- a/src/apps/chess/business_logic/_calculate_fen_before_move.py +++ b/src/apps/chess/business_logic/_calculate_fen_before_move.py @@ -2,7 +2,7 @@ import chess -from ..helpers import chess_lib_color_to_player_side +from ..chess_helpers import chess_lib_color_to_player_side if TYPE_CHECKING: from apps.chess.types import FEN, PlayerSide diff --git a/src/apps/chess/business_logic/_calculate_piece_available_targets.py b/src/apps/chess/business_logic/_calculate_piece_available_targets.py index 9d8ccf1..63e4929 100644 --- a/src/apps/chess/business_logic/_calculate_piece_available_targets.py +++ b/src/apps/chess/business_logic/_calculate_piece_available_targets.py @@ -2,7 +2,7 @@ import chess -from apps.chess.helpers import chess_lib_square_to_square +from apps.chess.chess_helpers import chess_lib_square_to_square if TYPE_CHECKING: from apps.chess.types import Square diff --git a/src/apps/chess/business_logic/_do_chess_move.py b/src/apps/chess/business_logic/_do_chess_move.py index 83ce257..211a2fd 100644 --- a/src/apps/chess/business_logic/_do_chess_move.py +++ b/src/apps/chess/business_logic/_do_chess_move.py @@ -3,7 +3,7 @@ import chess -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( chess_lib_piece_to_piece_type, file_and_rank_from_square, square_from_file_and_rank, diff --git a/src/apps/chess/helpers.py b/src/apps/chess/chess_helpers.py similarity index 96% rename from src/apps/chess/helpers.py rename to src/apps/chess/chess_helpers.py index 6117fce..bb34af8 100644 --- a/src/apps/chess/helpers.py +++ b/src/apps/chess/chess_helpers.py @@ -1,4 +1,4 @@ -from functools import cache, lru_cache +from functools import cache from typing import TYPE_CHECKING, cast import chess @@ -132,16 +132,19 @@ def get_squares_with_pieces_that_can_move(board: chess.Board) -> frozenset["Squa ) -@lru_cache def get_active_player_side_from_fen(fen: "FEN") -> "PlayerSide": return cast("PlayerSide", fen.split(" ")[1]) +def get_turns_counter_from_fen(fen: "FEN") -> int: + """Returns the fullmove number, starting from 1""" + return int(fen.split(" ")[-1]) + + def get_active_player_side_from_chess_board(board: chess.Board) -> "PlayerSide": return "w" if board.turn else "b" -@lru_cache def uci_move_squares(move: str) -> tuple["Square", "Square"]: return cast("Square", move[:2]), cast("Square", move[2:4]) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index efe2966..733bac2 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -9,7 +9,7 @@ from dominate.tags import button, div, section, span from dominate.util import raw as unescaped_html -from ..helpers import ( +from ..chess_helpers import ( file_and_rank_from_square, piece_name_from_piece_role, player_side_from_piece_role, @@ -623,7 +623,7 @@ def chess_unit_symbol_display( if player_side == "w" else "drop-shadow-piece-symbol-b" ), - chess_unit_symbol_class(player_side="w", piece_name=piece_name), + chess_unit_symbol_class(player_side=player_side, piece_name=piece_name), ) symbol_display = div( cls=" ".join(symbol_class), diff --git a/src/apps/chess/components/chess_helpers.py b/src/apps/chess/components/chess_helpers.py index aa1c919..e84d32e 100644 --- a/src/apps/chess/components/chess_helpers.py +++ b/src/apps/chess/components/chess_helpers.py @@ -1,12 +1,12 @@ from functools import cache from typing import TYPE_CHECKING -from apps.chess.consts import PIECE_TYPE_TO_NAME -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( file_and_rank_from_square, player_side_from_piece_role, type_from_piece_role, ) +from apps.chess.consts import PIECE_TYPE_TO_NAME if TYPE_CHECKING: from collections.abc import Sequence diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 910d9c8..dbb31b4 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -6,8 +6,7 @@ from apps.chess.business_logic import calculate_piece_available_targets -from .consts import PLAYER_SIDES -from .helpers import ( +from .chess_helpers import ( chess_lib_color_to_player_side, chess_lib_square_to_square, get_active_player_side_from_chess_board, @@ -16,6 +15,7 @@ symbol_from_piece_role, team_member_role_from_piece_role, ) +from .consts import PLAYER_SIDES from .models import UserPrefs from .types import ChessInvalidStateException @@ -243,20 +243,25 @@ class GamePresenterUrls(ABC): def __init__(self, *, game_presenter: GamePresenter): self._game_presenter = game_presenter + @abstractmethod def htmx_game_no_selection_url(self, *, board_id: str) -> str: - raise NotImplementedError + pass + @abstractmethod def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: - raise NotImplementedError + pass + @abstractmethod def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: - raise NotImplementedError + pass + @abstractmethod def htmx_game_play_bot_move_url(self, *, board_id: str) -> str: - raise NotImplementedError + pass + @abstractmethod def htmx_game_play_solution_move_url(self, *, board_id: str) -> str: - raise NotImplementedError + pass class SelectedSquarePresenter: diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index 7856375..edf9538 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -1,5 +1,6 @@ from typing import Literal, Required, TypeAlias, TypedDict +# https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation FEN: TypeAlias = str # fmt: off diff --git a/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py b/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py index 2e70fa7..a55c9c4 100644 --- a/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py +++ b/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from ...chess.business_logic import calculate_fen_before_move from ..consts import BOT_SIDE diff --git a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py index d88aa64..df618ee 100644 --- a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py +++ b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py @@ -3,7 +3,7 @@ from dominate.util import raw -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( chess_lib_square_to_square, player_side_to_chess_lib_color, team_member_role_from_piece_role, diff --git a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py index 49c139d..c487974 100644 --- a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py +++ b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING, cast from apps.chess.business_logic import do_chess_move -from apps.chess.helpers import get_active_player_side_from_fen +from apps.chess.chess_helpers import get_active_player_side_from_fen from apps.chess.types import ChessInvalidStateException from ..models import PlayerGameOverState diff --git a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py index 324a5bb..9dd6522 100644 --- a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py +++ b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py @@ -3,19 +3,19 @@ import chess -from apps.chess.data.team_member_names import FIRST_NAMES, LAST_NAMES -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( chess_lib_color_to_player_side, chess_lib_square_to_square, + piece_role_from_team_member_role_and_player_side, player_side_other, ) +from apps.chess.data.team_member_names import FIRST_NAMES, LAST_NAMES if TYPE_CHECKING: from apps.chess.types import ( FEN, Faction, GameTeams, - PieceRole, PieceRoleBySquare, PieceType, PlayerSide, @@ -74,17 +74,16 @@ def set_daily_challenge_teams_and_pieces_roles( team_member_role_counter, piece_role_max_value = team_members_counters[ piece_player_side ][piece_type] - piece_role = cast( - "PieceRole", + team_member_role = cast( + "TeamMemberRole", ( f"{piece_type}{team_member_role_counter}" if team_member_role_counter > 0 else piece_type ), ) - team_member_role = cast( - "TeamMemberRole", - piece_role.upper() if piece_player_side == "w" else piece_role, + piece_role = piece_role_from_team_member_role_and_player_side( + team_member_role, piece_player_side ) if team_member_role_counter > piece_role_max_value: @@ -94,7 +93,7 @@ def set_daily_challenge_teams_and_pieces_roles( ) square = chess_lib_square_to_square(chess_square) - piece_role_by_square[square] = team_member_role + piece_role_by_square[square] = piece_role team_member: "TeamMember" = { "role": team_member_role, diff --git a/src/apps/daily_challenge/business_logic/_undo_last_move.py b/src/apps/daily_challenge/business_logic/_undo_last_move.py index 7e90e2f..90048c2 100644 --- a/src/apps/daily_challenge/business_logic/_undo_last_move.py +++ b/src/apps/daily_challenge/business_logic/_undo_last_move.py @@ -2,7 +2,7 @@ import textwrap from typing import TYPE_CHECKING -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from ..models import DailyChallengeStats from ._move_daily_challenge_piece import move_daily_challenge_piece diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/misc_ui/status_bar.py index f4f7252..d1236e6 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/status_bar.py @@ -3,7 +3,7 @@ from dominate.tags import b, button, div, p from dominate.util import raw -from apps.chess.helpers import ( +from apps.chess.chess_helpers import ( piece_name_from_piece_role, player_side_from_piece_role, type_from_piece_role, diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 43f5c08..063919f 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -4,7 +4,7 @@ from django.urls import reverse -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from apps.chess.presenters import GamePresenter, GamePresenterUrls from .business_logic import get_speech_bubble diff --git a/src/apps/daily_challenge/tests/_helpers.py b/src/apps/daily_challenge/tests/_helpers.py index b59b87d..8705857 100644 --- a/src/apps/daily_challenge/tests/_helpers.py +++ b/src/apps/daily_challenge/tests/_helpers.py @@ -4,7 +4,7 @@ from django.utils.timezone import now -from apps.chess.helpers import uci_move_squares +from apps.chess.chess_helpers import uci_move_squares from ..models import DailyChallengeStats, PlayerSessionContent diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index f776266..a68d54a 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST, require_safe from django_htmx.http import HttpResponseClientRedirect -from apps.chess.helpers import get_active_player_side_from_fen, uci_move_squares +from apps.chess.chess_helpers import get_active_player_side_from_fen, uci_move_squares from apps.chess.types import ChessInvalidActionException, ChessInvalidMoveException from apps.utils.view_decorators import user_is_staff from apps.utils.views_helpers import htmx_aware_redirect @@ -67,6 +67,7 @@ def game_view(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: assert ( ctx.challenge.fen_before_bot_first_move and ctx.challenge.piece_role_by_square_before_bot_first_move + and ctx.challenge.bot_first_move ) ctx.game_state.fen = ctx.challenge.fen_before_bot_first_move @@ -293,6 +294,9 @@ def htmx_restart_daily_challenge_ask_confirmation( def htmx_restart_daily_challenge_do( request: "HttpRequest", *, ctx: "GameContext" ) -> HttpResponse: + # This field is always set on a published challenge: + assert ctx.challenge.bot_first_move + new_game_state = restart_daily_challenge( challenge=ctx.challenge, game_state=ctx.game_state, diff --git a/src/apps/lichess_bridge/components/lichess_account.py b/src/apps/lichess_bridge/components/lichess_account.py new file mode 100644 index 0000000..7856ed1 --- /dev/null +++ b/src/apps/lichess_bridge/components/lichess_account.py @@ -0,0 +1,41 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import button, div, form, p + +from apps.webui.components.forms_common import csrf_hidden_input + +if TYPE_CHECKING: + from django.http import HttpRequest + from dominate.tags import html_tag + + +def lichess_linked_account_inner_footer(request: "HttpRequest") -> "html_tag": + return div( + detach_lichess_account_form(request), + cls="my-4", + ) + + +def detach_lichess_account_form(request: "HttpRequest") -> form: + return form( + csrf_hidden_input(request), + p( + "You can disconnect your Lichess account from Zakuchess at any time.", + cls="text-center", + ), + p( + "Tap ", + button( + "here", + type="submit", + cls="text-rose-600 underline", + ), + " to disconnect it.", + cls="text-center", + ), + p("(you can always reconnect it later)", cls="text-center"), + action=reverse("lichess_bridge:detach_lichess_account"), + method="POST", + cls="mt-16 text-slate-50 text-sm", + ) diff --git a/src/apps/lichess_bridge/components/misc_ui.py b/src/apps/lichess_bridge/components/misc_ui.py index 99d44aa..dffd15f 100644 --- a/src/apps/lichess_bridge/components/misc_ui.py +++ b/src/apps/lichess_bridge/components/misc_ui.py @@ -1,26 +1,17 @@ -from typing import TYPE_CHECKING +_ONE_DAY = 86_400 +_TWO_DAYS = _ONE_DAY * 2 -from django.urls import reverse -from dominate.tags import button, form -from apps.webui.components import common_styles -from apps.webui.components.forms_common import csrf_hidden_input - -from .svg_icons import ICON_SVG_LOG_OUT - -if TYPE_CHECKING: - from django.http import HttpRequest - - -def detach_lichess_account_form(request: "HttpRequest") -> form: - return form( - csrf_hidden_input(request), - button( - "Log out from Lichess", - " ", - ICON_SVG_LOG_OUT, - cls=common_styles.BUTTON_CLASSES, - ), - action=reverse("lichess_bridge:detach_lichess_account"), - method="POST", - ) +def time_left_display(time_left_seconds: int) -> str: + # TODO: write a test for this + if time_left_seconds < 1: + return "time's up" + if time_left_seconds < 60: + return f"{time_left_seconds} seconds" + if time_left_seconds < 3600: + return f"{round(time_left_seconds/60)} minutes" + if time_left_seconds < _ONE_DAY: + return f"{round(time_left_seconds/3600)} hours" + if time_left_seconds < _TWO_DAYS: + return f"1 day and {round((time_left_seconds-_ONE_DAY)/3600)} hours" + return f"{round(time_left_seconds/_ONE_DAY)} days" diff --git a/src/apps/lichess_bridge/components/ongoing_games.py b/src/apps/lichess_bridge/components/ongoing_games.py new file mode 100644 index 0000000..7e46067 --- /dev/null +++ b/src/apps/lichess_bridge/components/ongoing_games.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import a, caption, div, table, tbody, td, th, thead, tr + +from ...chess.chess_helpers import get_turns_counter_from_fen +from .misc_ui import time_left_display + +if TYPE_CHECKING: + from dominate.tags import html_tag + + from ..models import LichessOngoingGameData + + +def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "html_tag": + th_classes = "border border-slate-300 dark:border-slate-600 font-semibold p-2 text-slate-900 dark:text-slate-200" + + return table( + caption("Your ongoing games", cls="mb-2"), + thead( + tr( + th("Opponent", cls=th_classes), + th("Moves", cls=th_classes), + th("Time", cls=th_classes), + th("Turn", cls=th_classes), + ), + ), + tbody( + *[_ongoing_game_row(game) for game in ongoing_games] + if ongoing_games + else tr( + td( + div( + "You have no ongoing games on Lichess at the moment", + cls="italic p-8 text-center", + ), + colspan=4, + ) + ), + ), + cls="w-full my-4 border-separate border-spacing-2 border border-slate-500 rounded-md", + ) + + +def _ongoing_game_row(game: "LichessOngoingGameData") -> tr: + td_classes = "border border-slate-300 dark:border-slate-700 p-1 text-slate-500 dark:text-slate-400" + return tr( + td( + a( + game.opponent.username, + href=reverse( + "lichess_bridge:correspondence_game", + kwargs={"game_id": game.gameId}, + ), + ), + cls=td_classes, + ), + td(get_turns_counter_from_fen(game.fen), cls=td_classes), + td(time_left_display(game.secondsLeft), cls=td_classes), + td("Mine" if game.isMyTurn else "Theirs", cls=td_classes), + ) diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 1be606c..6065549 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -3,32 +3,41 @@ from django.urls import reverse from dominate.tags import ( a, + b, button, div, fieldset, form, + h3, input_, label, legend, - li, p, section, - ul, ) +from apps.chess.components.chess_board import chess_arena from apps.webui.components import common_styles from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page -from ... import lichess_api from ...models import LichessCorrespondenceGameDaysChoice -from ..misc_ui import detach_lichess_account_form +from ...presenters import LichessCorrespondenceGamePresenter +from ..lichess_account import ( + detach_lichess_account_form, + lichess_linked_account_inner_footer, +) +from ..ongoing_games import lichess_ongoing_games from ..svg_icons import ICON_SVG_CREATE, ICON_SVG_LOG_IN if TYPE_CHECKING: from django.http import HttpRequest - from ...models import LichessAccessToken + from ...models import ( + LichessAccountInformation, + LichessGameExport, + LichessOngoingGameData, + ) def lichess_no_account_linked_page( @@ -57,36 +66,33 @@ def lichess_no_account_linked_page( ) -async def lichess_account_linked_homepage( +def lichess_account_linked_homepage( *, request: "HttpRequest", - access_token: "LichessAccessToken", + me: "LichessAccountInformation", + ongoing_games: "list[LichessOngoingGameData]", ) -> str: - me = await lichess_api.get_my_account(access_token=access_token) - ongoing_games = await lichess_api.get_my_ongoing_games(access_token=access_token) - return page( div( section( - f"Hello {me.username}!", - ul( - *[li(game.gameId) for game in ongoing_games], + h3( + "Logged in as ", + b(f"{me.username}@Lichess", cls="text-yellow-400 font-bold"), + cls="mb-2 border rounded-t-md border-slate-700 bg-slate-800 text-lg text-center", ), + lichess_ongoing_games(ongoing_games), p( a( "Create a new game", - href=reverse("lichess_bridge:create_correspondence_game"), + href=reverse("lichess_bridge:create_game"), cls=common_styles.BUTTON_CLASSES, ), ), - cls="text-slate-50", - ), - div( - detach_lichess_account_form(request), - cls="mt-4", + cls="text-center text-slate-50", ), - cls="w-full mx-auto bg-slate-900 min-h-48 " - "md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", + lichess_linked_account_inner_footer(request), + cls="w-full mx-auto bg-slate-900 min-h-48 pb-4 " + " md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", ), request=request, title="Lichess - account linked", @@ -160,3 +166,22 @@ def lichess_correspondence_game_creation_page( request=request, title="Lichess - new correspondence game", ) + + +def lichess_correspondence_game_page( + *, + request: "HttpRequest", + me: "LichessAccountInformation", + game_data: "LichessGameExport", +) -> str: + game_presenter = LichessCorrespondenceGamePresenter(game_data, my_player_id=me.id) + + return page( + chess_arena( + game_presenter=game_presenter, + status_bars=[], + board_id="main", + ), + request=request, + title=f"Lichess - correspondence game {game_data.id}", + ) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 77e7962..bae0307 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -1,93 +1,81 @@ -from typing import TYPE_CHECKING, Literal +import contextlib +import functools +import logging +import time +from typing import TYPE_CHECKING import httpx import msgspec from django.conf import settings -from .models import LICHESS_ACCESS_TOKEN_PREFIX +from .models import ( + LICHESS_ACCESS_TOKEN_PREFIX, + LichessAccountInformation, + LichessGameExport, + LichessOngoingGameData, +) if TYPE_CHECKING: - from .models import LichessAccessToken, LichessGameSeekId - - -class AccountInformation( - msgspec.Struct, -): - """Information about an account, as returned by the Lichess API.""" - - # N.B. There are many more fields than this - but we only use these at the moment - - id: str # e.g. "philippe" - username: str # e.g. "Philippe" - url: str # e.g. "https://lichess.org/@/dunsap" + from collections.abc import Iterator + from .models import LichessAccessToken, LichessGameSeekId -class OpponentData(msgspec.Struct): - """Information about an opponent, as returned by the Lichess API.""" - - id: str # e.g. "philippe" - rating: int # e.g. 1790 - username: str # e.g. "Philippe" - - -class OngoingGameData(msgspec.Struct): - """Information about an ongoing game, as returned by the Lichess API.""" - - gameId: str - fullId: str - color: Literal["white", "black"] - fen: str - hasMoved: bool - isMyTurn: bool - lastMove: str # e.g. "b8c6" - opponent: OpponentData - perf: Literal["correspondence"] # TODO: other values? - rated: bool - secondsLeft: int - source: Literal["lobby", "friend"] # TODO: other values? - speed: Literal["correspondence"] # TODO: other values? - variant: dict[str, str] +_logger = logging.getLogger(__name__) def is_lichess_api_access_token_valid(token: str) -> bool: return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10 -async def get_my_account( - *, - access_token: "LichessAccessToken", -) -> AccountInformation: - async with _create_lichess_api_client(access_token) as client: - # https://lichess.org/api#tag/Account/operation/accountMe - response = await client.get("/api/account") - return msgspec.json.decode(response.content, type=AccountInformation) +@functools.lru_cache(maxsize=32) +async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInformation: + # https://lichess.org/api#tag/Account/operation/accountMe + endpoint = "/api/account" + with _lichess_api_monitoring("GET", endpoint): + response = await api_client.get(endpoint) + return msgspec.json.decode(response.content, type=LichessAccountInformation) async def get_my_ongoing_games( *, - access_token: "LichessAccessToken", + api_client: httpx.AsyncClient, count: int = 5, -) -> list[OngoingGameData]: - async with _create_lichess_api_client(access_token) as client: - # https://lichess.org/api#tag/Games/operation/apiAccountPlaying - response = await client.get("/api/account/playing", params={"nb": count}) +) -> list[LichessOngoingGameData]: + # https://lichess.org/api#tag/Games/operation/apiAccountPlaying + endpoint = "/api/account/playing" + with _lichess_api_monitoring("GET", endpoint): + response = await api_client.get(endpoint, params={"nb": count}) + + class ResponseDataWrapper(msgspec.Struct): + """The ongoing games are wrapped in a "nowPlaying" root object's key""" + + nowPlaying: list[LichessOngoingGameData] + + return msgspec.json.decode(response.content, type=ResponseDataWrapper).nowPlaying + - class ResponseDataWrapper(msgspec.Struct): - nowPlaying: list[OngoingGameData] +async def get_game_by_id( + *, api_client: httpx.AsyncClient, game_id: str +) -> LichessGameExport: + # https://lichess.org/api#tag/Games/operation/gamePgn + endpoint = f"/game/export/{game_id}" + with _lichess_api_monitoring("GET", endpoint): + # We only need the FEN, but it seems that the Lichess "game by ID" API endpoints + # can only return the full PGN - which will require a bit more work to parse. + response = await api_client.get(endpoint, params={"pgnInJson": "true"}) - return msgspec.json.decode( - response.content, type=ResponseDataWrapper - ).nowPlaying + return msgspec.json.decode(response.content, type=LichessGameExport) async def create_correspondence_game( - *, access_token: "LichessAccessToken", days_per_turn: int + *, api_client: httpx.AsyncClient, days_per_turn: int ) -> "LichessGameSeekId": - async with _create_lichess_api_client(access_token) as client: - # https://lichess.org/api#tag/Board/operation/apiBoardSeek - # TODO: give more customisation options to the user - response = await client.post( - "/api/board/seek", + # https://lichess.org/api#tag/Board/operation/apiBoardSeek + # TODO: give more customisation options to the user + endpoint = "/api/board/seek" + with _lichess_api_monitoring("POST", endpoint): + response = await api_client.post( + endpoint, json={ "rated": False, "days": days_per_turn, @@ -95,12 +83,32 @@ async def create_correspondence_game( "color": "random", }, ) - return str(response.json()["id"]) + return str(response.json()["id"]) + + +def get_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncClient: + return _create_lichess_api_client(access_token) + + +@contextlib.contextmanager +def _lichess_api_monitoring(target_endpoint, method) -> "Iterator[None]": + start_time = time.monotonic() + yield + _logger.info( + "Lichess API: %s '%s' took %ims.", + method, + target_endpoint, + (time.monotonic() - start_time) * 1000, + ) -# This is the function we'll mock during tests: +# This is the function we'll mock during tests - as it's private, we don't have to +# mind about it being directly imported by other modules when we mock it. def _create_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncClient: return httpx.AsyncClient( base_url=settings.LICHESS_HOST, - headers={"Authorization": f"Bearer {access_token}"}, + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + }, ) diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 8e79711..fc43e82 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -1,15 +1,81 @@ -from typing import TypeAlias +from typing import Literal, TypeAlias +import msgspec from django.db import models +from apps.chess.types import FEN # used by msgspec, so it has to be a "real" import + LichessAccessToken: TypeAlias = str # e.g. "lio_6EeGimHMalSVH9qMcfUc2JJ3xdBPlqrL" + +LichessPlayerId: TypeAlias = str # e.g. "dunsap" +LichessPlayerFullId: TypeAlias = str # e.g. "Dunsap" + LichessGameSeekId: TypeAlias = str # e.g. "oIsGhJaf" +LichessGameId: TypeAlias = str # e.g. "tFfGsEpb" (always 8 chars) +LichessGameFullId: TypeAlias = str # e.g. "tFfGsEpbd0mL" (always 12 chars?) + # > By convention tokens have a recognizable prefix, but do not rely on this. # Well... Let's still rely on this for now ^^ # TODO: remove this, as it may break one day? LICHESS_ACCESS_TOKEN_PREFIX = "lio_" +# The values of these enums can be found in the OpenAPI spec one can download +# by clicking the "Download" button at the top of this page: +# https://lichess.org/api +# (for some reason the JSON file is not directly linkable) +LichessChessVariant = Literal[ + # we only support "standard" for now 😅 + # But as python-chess supports many variants, we could support more in the future. + # (https://python-chess.readthedocs.io/en/latest/variant.html) + "standard", + "chess960", + "crazyhouse", + "antichess", + "atomic", + "horde", + "kingOfTheHill", + "racingKings", + "threeCheck", + "fromPosition", +] +LichessPlayerSide = Literal["white", "black"] +LichessGameSource = Literal["lobby", "friend"] # TODO: other values? +LichessGameSpeed = Literal[ + "ultraBullet", "bullet", "blitz", "rapid", "classical", "correspondence" +] +LichessGamePerf = Literal[ + "ultraBullet", + "bullet", + "blitz", + "rapid", + "classical", + "correspondence", + "chess960", + "crazyhouse", + "antichess", + "atomic", + "horde", + "kingOfTheHill", + "racingKings", + "threeCheck", +] +LichessGameStatus = Literal[ + "created", + "started", + "aborted", + "mate", + "resign", + "stalemate", + "timeout", + "draw", + "outoftime", + "cheat", + "noStart", + "unknownFinish", + "variantEnd", +] + class LichessCorrespondenceGameDaysChoice(models.IntegerChoices): # https://lichess.org/api#tag/Board/operation/apiBoardSeek @@ -20,3 +86,88 @@ class LichessCorrespondenceGameDaysChoice(models.IntegerChoices): SEVEN_DAYS = (7, "7 days") TEN_DAYS = (10, "10 days") FOURTEEN_DAYS = (14, "14 days") + + +class LichessAccountInformation( + msgspec.Struct, +): + """Information about an account, as returned by the Lichess API.""" + + # N.B. There are many more fields than this - but we only use these at the moment + + id: LichessPlayerId + username: LichessPlayerFullId + url: str # e.g. "https://lichess.org/@/dunsap" + + +class LichessOpponentData(msgspec.Struct): + """Information about an opponent, as returned by the Lichess API.""" + + id: LichessPlayerId + username: LichessPlayerFullId + rating: int # e.g. 1790 + + +class LichessOngoingGameData(msgspec.Struct): + """Information about an ongoing game, as returned by the Lichess API.""" + + gameId: LichessGameId + fullId: LichessGameFullId + color: LichessPlayerSide + fen: FEN + hasMoved: bool + isMyTurn: bool + lastMove: str # e.g. "b8c6" + opponent: LichessOpponentData + perf: LichessGamePerf + rated: bool + secondsLeft: int + source: LichessGameSource + speed: LichessGameSpeed + variant: dict[str, str] + + +class LichessGameUser(msgspec.Struct): + id: LichessPlayerId + name: LichessGameFullId + + +class LichessGameOpening(msgspec.Struct): + eco: str # e.g. "B01" + name: str # e.g. "Scandinavian Defense" + ply: int # e.g. 2 + + +class LichessGamePlayer(msgspec.Struct): + user: LichessGameUser + rating: int + provisional: bool = False + + +class LichessGamePlayers(msgspec.Struct): + white: LichessGamePlayer + black: LichessGamePlayer + + +class LichessGameExport(msgspec.Struct): + """ + Information about a game as given by an "export game" endpoint, + as returned by the Lichess API. + """ + + id: LichessGameId + fullId: LichessGameFullId + rated: bool + variant: str + speed: LichessGameSpeed + perf: LichessGamePerf + createdAt: int + lastMoveAt: int + status: LichessGameStatus + source: LichessGameSource + players: LichessGamePlayers + opening: LichessGameOpening + moves: str + pgn: str + daysPerTurn: int + division: dict # ??? diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py new file mode 100644 index 0000000..2e11ec8 --- /dev/null +++ b/src/apps/lichess_bridge/presenters.py @@ -0,0 +1,212 @@ +import io +from functools import cached_property +from typing import TYPE_CHECKING, NamedTuple, Self, cast + +import chess +import chess.pgn + +from apps.chess.presenters import GamePresenter, GamePresenterUrls + +from ..chess.chess_helpers import ( + chess_lib_color_to_player_side, + chess_lib_square_to_square, + team_member_role_from_piece_role, +) + +if TYPE_CHECKING: + from apps.chess.presenters import SpeechBubbleData + from apps.chess.types import ( + FEN, + Factions, + GamePhase, + GameTeams, + PieceRole, + PieceRoleBySquare, + PieceSymbol, + PlayerSide, + Square, + TeamMember, + ) + + from .models import ( + LichessGameExport, + LichessGameUser, + LichessPlayerFullId, + LichessPlayerId, + LichessPlayerSide, + ) + +# Presenters are the objects we pass to our templates. +_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict["LichessPlayerSide", "PlayerSide"] = { + "white": "w", + "black": "b", +} + + +class _LichessGamePlayer(NamedTuple): + id: "LichessPlayerId" + username: "LichessPlayerFullId" + player_side: "PlayerSide" + + +class _LichessGamePlayers(NamedTuple): + me: _LichessGamePlayer + them: _LichessGamePlayer + + @classmethod + def from_game_data( + cls, game_data: "LichessGameExport", my_player_id: "LichessPlayerId" + ) -> Self: + my_side: "LichessPlayerSide" = ( + "white" if game_data.players.white.user.id == my_player_id else "black" + ) + their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + my_player: "LichessGameUser" = getattr(game_data.players, my_side).user + their_player: "LichessGameUser" = getattr(game_data.players, their_side).user + + return cls( + me=_LichessGamePlayer( + id=my_player.id, + username=my_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], + ), + them=_LichessGamePlayer( + id=their_player.id, + username=their_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], + ), + ) + + +class LichessCorrespondenceGamePresenter(GamePresenter): + def __init__(self, game_data: "LichessGameExport", my_player_id: "LichessPlayerId"): + self._my_player_id = my_player_id + self._game_data = game_data + + pgn_game = chess.pgn.read_game(io.StringIO(game_data.pgn)) + if not pgn_game: + raise ValueError("Could not read PGN game") + self._chess_board = pgn_game.end().board() + fen = cast("FEN", self._chess_board.fen()) + + teams, piece_role_by_square = self._create_teams_and_piece_role_by_square( + self._chess_board, self.factions + ) + + super().__init__( + fen=fen, + piece_role_by_square=piece_role_by_square, + teams=teams, + refresh_last_move=True, + is_htmx_request=False, + ) + + @cached_property + def urls(self) -> "GamePresenterUrls": + return LichessCorrespondenceGamePresenterUrls(game_presenter=self) + + @cached_property + def is_my_turn(self) -> bool: + return True # TODO + + @cached_property + def game_phase(self) -> "GamePhase": + return "waiting_for_player_selection" # TODO + + @cached_property + def is_player_turn(self) -> bool: + return True # TODO + + @cached_property + def is_bot_turn(self) -> bool: + return False + + @property + def solution_index(self) -> int | None: + return None + + @cached_property + def game_id(self) -> str: + return self._game_data.id + + @cached_property + def factions(self) -> "Factions": + return { + self._players.me.player_side: "humans", + self._players.them.player_side: "undeads", + } + + @property + def is_intro_turn(self) -> bool: + return False + + @cached_property + def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": + return None + + @cached_property + def speech_bubble(self) -> "SpeechBubbleData | None": + return None + + @cached_property + def _players(self) -> _LichessGamePlayers: + return _LichessGamePlayers.from_game_data(self._game_data, self._my_player_id) + + @staticmethod + def _create_teams_and_piece_role_by_square( + chess_board: "chess.Board", factions: "Factions" + ) -> "tuple[GameTeams, PieceRoleBySquare]": + # fmt: off + piece_counters:dict["PieceSymbol", int | None] = { + "P": 0, "R": 0, "N": 0, "B": 0, "Q": None, "K": None, + "p": 0, "r": 0, "n": 0, "b": 0, "q": None, "k": None, + } + # fmt: on + + teams: "GameTeams" = {"w": [], "b": []} + piece_role_by_square: "PieceRoleBySquare" = {} + for chess_square in chess.SQUARES: + piece = chess_board.piece_at(chess_square) + if not piece: + continue + + player_side = chess_lib_color_to_player_side(piece.color) + symbol = cast("PieceSymbol", piece.symbol()) # e.g. "P", "p", "R", "r"... + + if piece_counters[symbol] is not None: + piece_counters[symbol] += 1 # type: ignore[operator] + piece_role = cast( + "PieceRole", f"{symbol}{piece_counters[symbol]}" + ) # e.g "P1", "r2".... + else: + piece_role = cast("PieceRole", symbol) # e.g. "Q", "k"... + + team_member_role = team_member_role_from_piece_role(piece_role) + team_member: "TeamMember" = { + "role": team_member_role, + "name": "", + "faction": factions[player_side], + } + teams[player_side].append(team_member) + + square = chess_lib_square_to_square(chess_square) + piece_role_by_square[square] = piece_role + + return teams, piece_role_by_square + + +class LichessCorrespondenceGamePresenterUrls(GamePresenterUrls): + def htmx_game_no_selection_url(self, *, board_id: str) -> str: + return "#" # TODO + + def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: + return "#" # TODO + + def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: + return "#" # TODO + + def htmx_game_play_bot_move_url(self, *, board_id: str) -> str: + return "#" # TODO + + def htmx_game_play_solution_move_url(self, *, board_id: str) -> str: + return "#" # TODO diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index cee6a91..3156a2f 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -60,11 +60,11 @@ async def get(self, path, **kwargs): response = await async_client.get("/lichess/") - assert create_lichess_api_client_mock.call_count == 2 + assert create_lichess_api_client_mock.call_count == 1 assert response.status_code == HTTPStatus.OK response_html = response.content.decode("utf-8") assert "Log in via Lichess" not in response_html - assert "Log out from Lichess" in response_html + assert "disconnect your Lichess account" in response_html assert "ChessChampion" in response_html diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index 1f02eb4..d28a22f 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -9,8 +9,13 @@ # Game management Views: path( "games/new/", - views.lichess_correspondence_game_create, - name="create_correspondence_game", + views.lichess_game_create, + name="create_game", + ), + path( + "games/correspondence//", + views.lichess_correspondence_game, + name="correspondence_game", ), # OAuth2 Views: path( diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index f3bca75..d7f4101 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -1,3 +1,4 @@ +import asyncio from typing import TYPE_CHECKING from django.http import HttpResponse, HttpResponseRedirect @@ -25,7 +26,7 @@ if TYPE_CHECKING: from django.http import HttpRequest - from .models import LichessAccessToken + from .models import LichessAccessToken, LichessGameId # TODO: use Django message framework for everything that happens outside of the chess # board, so we can notify users of what's going on @@ -40,9 +41,22 @@ async def lichess_home( if not lichess_access_token: page_content = lichess_pages.lichess_no_account_linked_page(request=request) else: - page_content = await lichess_pages.lichess_account_linked_homepage( + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # As the queries are unrelated, let's run them in parallel: + async with asyncio.TaskGroup() as tg: + me = tg.create_task( + lichess_api.get_my_account(api_client=lichess_api_client) + ) + ongoing_games = tg.create_task( + lichess_api.get_my_ongoing_games(api_client=lichess_api_client) + ) + + page_content = lichess_pages.lichess_account_linked_homepage( request=request, - access_token=lichess_access_token, + me=me.result(), + ongoing_games=ongoing_games.result(), ) return HttpResponse(page_content) @@ -51,7 +65,7 @@ async def lichess_home( @require_http_methods(["GET", "POST"]) @with_lichess_access_token @redirect_if_no_lichess_access_token -async def lichess_correspondence_game_create( +async def lichess_game_create( request: "HttpRequest", lichess_access_token: "LichessAccessToken" ) -> HttpResponse: form_errors = {} @@ -60,11 +74,15 @@ async def lichess_correspondence_game_create( if not form.is_valid(): form_errors = form.errors else: - # N.B. This function returns a Lichess "Seek ID", but we don't use it atm. - await lichess_api.create_correspondence_game( - access_token=lichess_access_token, - days_per_turn=form.cleaned_data["days_per_turn"], - ) + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # N.B. This function returns a Lichess "Seek ID", + # but we don't use it atm. + await lichess_api.create_correspondence_game( + api_client=lichess_api_client, + days_per_turn=form.cleaned_data["days_per_turn"], + ) return redirect("lichess_bridge:homepage") @@ -75,6 +93,37 @@ async def lichess_correspondence_game_create( ) +@require_safe +@with_lichess_access_token +@redirect_if_no_lichess_access_token +async def lichess_correspondence_game( + request: "HttpRequest", + lichess_access_token: "LichessAccessToken", + game_id: "LichessGameId", +) -> HttpResponse: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # As the queries are unrelated, let's run them in parallel: + async with asyncio.TaskGroup() as tg: + me = tg.create_task( + lichess_api.get_my_account(api_client=lichess_api_client) + ) + game_data = tg.create_task( + lichess_api.get_game_by_id( + api_client=lichess_api_client, game_id=game_id + ) + ) + + return HttpResponse( + lichess_pages.lichess_correspondence_game_page( + request=request, + me=me.result(), + game_data=game_data.result(), + ) + ) + + @require_POST def lichess_redirect_to_oauth2_flow_starting_url( request: "HttpRequest", From 0cb76feedda975f74ac46fee8c4238061f64f517 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Mon, 9 Sep 2024 12:47:29 +0100 Subject: [PATCH 10/42] [lichess] Add some very basic tests --- pyproject.toml | 4 + .../components/pages/lichess_pages.py | 2 +- src/apps/lichess_bridge/tests/test_views.py | 162 +++++++++++++++--- uv.lock | 15 ++ 4 files changed, 156 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 74424a3..7395db3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ test = [ "time-machine==2.*", "pytest-blockage==0.2.*", "pytest-asyncio==0.24.*", + "pytest-httpx-blockage==0.0.8", ] load-testing = [ "locust==2.*", @@ -142,6 +143,9 @@ asyncio_mode = "auto" # pytest-blockage settings: # https://github.com/rob-b/pytest-blockage blockage = true +# pytest-httpx-blockage settings: +# https://github.com/SlavaSkvortsov/pytest-httpx-blockage +blockage-httpx = true [tool.coverage.run] # @link https://coverage.readthedocs.io/en/latest/excluding.html diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 6065549..ac9b6cf 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -151,7 +151,7 @@ def lichess_correspondence_game_creation_page( type="submit", cls=common_styles.BUTTON_CLASSES, ), - action=reverse("lichess_bridge:create_correspondence_game"), + action=reverse("lichess_bridge:create_game"), method="POST", ), cls="text-slate-50", diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 3156a2f..fce5317 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -25,35 +25,37 @@ async def test_lichess_homepage_with_access_token_smoke_test( access_token = "lio_123456789" async_client.cookies["lichess.access_token"] = access_token + + class HttpClientMock: + class HttpClientResponseMock: + def __init__(self, path): + self.path = path + + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case "/api/account/playing": + result = {"nowPlaying": []} + case _: + raise ValueError(f"Unexpected path: {self.path}") + return json.dumps(result) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith("/api/") + return self.HttpClientResponseMock(path) + with mock.patch( "apps.lichess_bridge.lichess_api._create_lichess_api_client", ) as create_lichess_api_client_mock: - - class HttpClientMock: - class HttpClientResponseMock: - def __init__(self, path): - self.path = path - - @property - def content(self) -> str: - # The client's response's `content` is a property - result: dict[str, Any] = {} - match self.path: - case "/api/account": - result = { - "id": "chesschampion", - "url": "https://lichess.org/@/chesschampion", - "username": "ChessChampion", - } - case "/api/account/playing": - result = {"nowPlaying": []} - return json.dumps(result) - - async def get(self, path, **kwargs): - # The client's `get` method is async - assert path.startswith("/api/") - return self.HttpClientResponseMock(path) - create_lichess_api_client_mock.return_value.__aenter__.return_value = ( HttpClientMock() ) @@ -68,3 +70,111 @@ async def get(self, path, **kwargs): assert "Log in via Lichess" not in response_html assert "disconnect your Lichess account" in response_html assert "ChessChampion" in response_html + + +async def test_lichess_create_game_without_access_token_should_redirect( + async_client: "DjangoAsyncClient", +): + response = await async_client.get("/lichess/games/new/") + + assert response.status_code == HTTPStatus.FOUND + + +async def test_lichess_create_game_with_access_token_smoke_test( + async_client: "DjangoAsyncClient", +): + """Just a quick smoke test for now""" + + access_token = "lio_123456789" + async_client.cookies["lichess.access_token"] = access_token + + response = await async_client.get("/lichess/games/new/") + + assert response.status_code == HTTPStatus.OK + + +async def test_lichess_correspondence_game_without_access_token_should_redirect( + async_client: "DjangoAsyncClient", +): + response = await async_client.get("/lichess/games/correspondence/tFXGsEvq/") + + assert response.status_code == HTTPStatus.FOUND + + +_LICHESS_CORRESPONDENCE_GAME_JSON_RESPONSE = { + "id": "tFfGsEpb", + "fullId": "tFfGsEpbd0mL", + "rated": False, + "variant": "standard", + "speed": "correspondence", + "perf": "correspondence", + "createdAt": 1725637044590, + "lastMoveAt": 1725714989179, + "status": "started", + "source": "lobby", + "players": { + "white": { + "user": {"name": "ChessChampion", "id": "chesschampion"}, + "rating": 1500, + "provisional": True, + }, + "black": { + "user": {"name": "ChessMaster74960", "id": "chessmaster74960"}, + "rating": 2078, + }, + }, + "opening": {"eco": "B01", "name": "Scandinavian Defense", "ply": 2}, + "moves": "e4 d5", + "pgn": '[Event "Casual correspondence game"]\n[Site "https://lichess.org/tFXGsEcq"]\n[Date "2024.09.06"]\n[White "ChessChampion"]\n[Black "ChessMaster74960"]\n[Result "*"]\n[UTCDate "2024.09.06"]\n[UTCTime "15:37:24"]\n[WhiteElo "1500"]\n[BlackElo "2078"]\n[Variant "Standard"]\n[TimeControl "-"]\n[ECO "B01"]\n[Opening "Scandinavian Defense"]\n[Termination "Unterminated"]\n\n1. e4 d5 *\n\n\n', + "daysPerTurn": 3, + "division": {}, +} + + +async def test_lichess_correspondence_game_with_access_token_smoke_test( + async_client: "DjangoAsyncClient", +): + """Just a quick smoke test for now""" + + access_token = "lio_123456789" + async_client.cookies["lichess.access_token"] = access_token + + class HttpClientMock: + class HttpClientResponseMock: + def __init__(self, path): + self.path = path + + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case "/game/export/tFfGsEpb": + result = _LICHESS_CORRESPONDENCE_GAME_JSON_RESPONSE + case _: + raise ValueError(f"Unexpected path: {self.path}") + return json.dumps(result) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith(("/api/", "/game/export/")) + return self.HttpClientResponseMock(path) + + with mock.patch( + "apps.lichess_bridge.lichess_api._create_lichess_api_client", + ) as create_lichess_api_client_mock: + create_lichess_api_client_mock.return_value.__aenter__.return_value = ( + HttpClientMock() + ) + + response = await async_client.get("/lichess/games/correspondence/tFfGsEpb/") + + assert create_lichess_api_client_mock.call_count == 1 + + assert response.status_code == HTTPStatus.OK diff --git a/uv.lock b/uv.lock index bd40e5b..bc0ef54 100644 --- a/uv.lock +++ b/uv.lock @@ -1166,6 +1166,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723 }, ] +[[package]] +name = "pytest-httpx-blockage" +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/5c/330fa2d41d76f63b66a3f629da3aa04426953f988ccfbeb844f4d72fe836/pytest-httpx-blockage-0.0.8.tar.gz", hash = "sha256:c0eac96c806ab4c7842716bf13a15aa5846e4ddda87e810c8f0c7d91561e2c6d", size = 4590 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/13/49b0b11e681e10e23fa6ef6a3b872af7df7dc543f7dcd850b4dc37420819/pytest_httpx_blockage-0.0.8-py3-none-any.whl", hash = "sha256:7dbeea3005580a01e7a645784d6d12ba21c394c1f4307b7a6d2d382e693eaab7", size = 5504 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1801,6 +1814,7 @@ test = [ { name = "pytest-blockage" }, { name = "pytest-cov" }, { name = "pytest-django" }, + { name = "pytest-httpx-blockage" }, { name = "time-machine" }, ] @@ -1829,6 +1843,7 @@ requires-dist = [ { name = "pytest-blockage", marker = "extra == 'test'", specifier = "==0.2.*" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = "==4.*" }, { name = "pytest-django", marker = "extra == 'test'", specifier = "==4.*" }, + { name = "pytest-httpx-blockage", marker = "extra == 'test'", specifier = "==0.0.8" }, { name = "python-dotenv", marker = "extra == 'dev'", specifier = "==1.*" }, { name = "requests", specifier = "==2.*" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.6.*" }, From 3b4673def0e8d8805d139780bae0d36e1e4f8c33 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Mon, 9 Sep 2024 21:31:56 +0100 Subject: [PATCH 11/42] [lichess] We can now select one of our pieces, via HTMX Baby steps! Still ugly as hell, but we'll get there :-) --- Makefile | 2 + src/apps/daily_challenge/presenters.py | 2 - src/apps/daily_challenge/tests/conftest.py | 7 -- src/apps/daily_challenge/tests/test_views.py | 2 +- .../components/pages/lichess_pages.py | 60 ++++++++++-- src/apps/lichess_bridge/lichess_api.py | 77 ++++++++++++--- src/apps/lichess_bridge/presenters.py | 32 ++++++- src/apps/lichess_bridge/tests/test_views.py | 8 ++ src/apps/lichess_bridge/urls.py | 10 ++ src/apps/lichess_bridge/views.py | 93 +++++++++++++++---- src/apps/lichess_bridge/views_decorators.py | 24 +++++ 11 files changed, 264 insertions(+), 53 deletions(-) diff --git a/Makefile b/Makefile index e45f651..b66fd39 100644 --- a/Makefile +++ b/Makefile @@ -42,12 +42,14 @@ backend/watch: env_vars ?= backend/watch: address ?= localhost backend/watch: port ?= 8000 backend/watch: dotenv_file ?= .env.local +backend/watch: uvicorn_opts ?= --use-colors --access-log backend/watch: ## Start Django via Uvicorn, in "watch" mode @DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ ${UV} run uvicorn \ --reload --reload-dir src/ \ --host ${address} --port ${port} \ --env-file ${dotenv_file} \ + ${uvicorn_opts} \ project.asgi:application .PHONY: backend/resetdb diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 063919f..c65b627 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -31,7 +31,6 @@ def __init__( is_htmx_request: bool, forced_bot_move: tuple["Square", "Square"] | None = None, forced_speech_bubble: tuple["Square", str] | None = None, - selected_square: "Square | None" = None, selected_piece_square: "Square | None" = None, target_to_confirm: "Square | None" = None, is_bot_move: bool = False, @@ -51,7 +50,6 @@ def __init__( teams=challenge.teams, refresh_last_move=refresh_last_move, is_htmx_request=is_htmx_request, - selected_square=selected_square, selected_piece_square=selected_piece_square, target_to_confirm=target_to_confirm, forced_bot_move=forced_bot_move, diff --git a/src/apps/daily_challenge/tests/conftest.py b/src/apps/daily_challenge/tests/conftest.py index 772e95e..c2ec08c 100644 --- a/src/apps/daily_challenge/tests/conftest.py +++ b/src/apps/daily_challenge/tests/conftest.py @@ -21,13 +21,6 @@ } -@pytest.fixture -def cleared_django_cache(): - from django.core.cache import cache - - cache.clear() - - @pytest.fixture def challenge_minimalist() -> DailyChallenge: """ diff --git a/src/apps/daily_challenge/tests/test_views.py b/src/apps/daily_challenge/tests/test_views.py index a2eb48a..fa7c0a3 100644 --- a/src/apps/daily_challenge/tests/test_views.py +++ b/src/apps/daily_challenge/tests/test_views.py @@ -291,7 +291,7 @@ def test_stats_modal_can_display_todays_victory_metrics_test( # Test dependencies challenge_minimalist: "DailyChallenge", client: "DjangoClient", - cleared_django_cache, + cleared_django_default_cache, ): get_current_challenge_mock.return_value = challenge_minimalist diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index ac9b6cf..42cd246 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +from django.conf import settings from django.urls import reverse from dominate.tags import ( a, @@ -16,13 +17,18 @@ section, ) -from apps.chess.components.chess_board import chess_arena +from apps.chess.components.chess_board import ( + chess_arena, + chess_available_targets, + chess_last_move, + chess_pieces, +) +from apps.chess.components.misc_ui import speech_bubble_container from apps.webui.components import common_styles from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page from ...models import LichessCorrespondenceGameDaysChoice -from ...presenters import LichessCorrespondenceGamePresenter from ..lichess_account import ( detach_lichess_account_form, lichess_linked_account_inner_footer, @@ -35,9 +41,9 @@ from ...models import ( LichessAccountInformation, - LichessGameExport, LichessOngoingGameData, ) + from ...presenters import LichessCorrespondenceGamePresenter def lichess_no_account_linked_page( @@ -171,11 +177,8 @@ def lichess_correspondence_game_creation_page( def lichess_correspondence_game_page( *, request: "HttpRequest", - me: "LichessAccountInformation", - game_data: "LichessGameExport", + game_presenter: "LichessCorrespondenceGamePresenter", ) -> str: - game_presenter = LichessCorrespondenceGamePresenter(game_data, my_player_id=me.id) - return page( chess_arena( game_presenter=game_presenter, @@ -183,5 +186,46 @@ def lichess_correspondence_game_page( board_id="main", ), request=request, - title=f"Lichess - correspondence game {game_data.id}", + title=f"Lichess - correspondence game {game_presenter.game_id}", + ) + + +def lichess_game_moving_parts_fragment( + *, + game_presenter: "LichessCorrespondenceGamePresenter", + request: "HttpRequest", + board_id: str, +) -> str: + return "\n".join( + ( + dom_tag.render(pretty=settings.DEBUG) + for dom_tag in ( + chess_pieces( + game_presenter=game_presenter, + board_id=board_id, + ), + chess_available_targets( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + ( + chess_last_move( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ) + if game_presenter.refresh_last_move + else div("") + ), + div( + speech_bubble_container( + game_presenter=game_presenter, + board_id=board_id, + ), + id=f"chess-speech-container-{board_id}", + data_hx_swap_oob="innerHTML", + ), + ) + ) ) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index bae0307..54c620f 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -1,5 +1,5 @@ import contextlib -import functools +import datetime as dt import logging import time from typing import TYPE_CHECKING @@ -7,6 +7,7 @@ import httpx import msgspec from django.conf import settings +from django.core.cache import cache from .models import ( LICHESS_ACCESS_TOKEN_PREFIX, @@ -23,17 +24,41 @@ _logger = logging.getLogger(__name__) +_GET_MY_ACCOUNT_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_my_account::{lichess_access_token}", + "DURATION": dt.timedelta(seconds=120).total_seconds(), +} + +_GET_GAME_BY_ID_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_game_by_id::{lichess_access_token}::{game_id}", + "DURATION": dt.timedelta(seconds=30).total_seconds(), +} + + def is_lichess_api_access_token_valid(token: str) -> bool: return token.startswith(LICHESS_ACCESS_TOKEN_PREFIX) and len(token) > 10 -@functools.lru_cache(maxsize=32) async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInformation: - # https://lichess.org/api#tag/Account/operation/accountMe - endpoint = "/api/account" - with _lichess_api_monitoring("GET", endpoint): - response = await api_client.get(endpoint) - return msgspec.json.decode(response.content, type=LichessAccountInformation) + """ + This is cached for a short amount of time. + """ + cache_key = _GET_MY_ACCOUNT_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + lichess_access_token=api_client.lichess_access_token # type: ignore[attr-defined] + ) + if cached_data := cache.get(cache_key): + _logger.info("Using cached data for 'get_my_account'.") + response_content = cached_data + else: + # https://lichess.org/api#tag/Account/operation/accountMe + endpoint = "/api/account" + with _lichess_api_monitoring("GET", endpoint): + response = await api_client.get(endpoint) + + response_content = response.content + cache.set(cache_key, response_content, _GET_MY_ACCOUNT_CACHE["DURATION"]) + + return msgspec.json.decode(response_content, type=LichessAccountInformation) async def get_my_ongoing_games( @@ -57,14 +82,29 @@ class ResponseDataWrapper(msgspec.Struct): async def get_game_by_id( *, api_client: httpx.AsyncClient, game_id: str ) -> LichessGameExport: - # https://lichess.org/api#tag/Games/operation/gamePgn - endpoint = f"/game/export/{game_id}" - with _lichess_api_monitoring("GET", endpoint): - # We only need the FEN, but it seems that the Lichess "game by ID" API endpoints - # can only return the full PGN - which will require a bit more work to parse. - response = await api_client.get(endpoint, params={"pgnInJson": "true"}) + """ + This is cached for a short amount of time, so we don't re-fetch the same games again + while the player is selecting pieces. + """ + cache_key = _GET_GAME_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + lichess_access_token=api_client.lichess_access_token, # type: ignore[attr-defined] + game_id=game_id, + ) + if cached_data := cache.get(cache_key): + _logger.info("Using cached data for 'get_game_by_id'.") + response_content = cached_data + else: + # https://lichess.org/api#tag/Games/operation/gamePgn + endpoint = f"/game/export/{game_id}" + with _lichess_api_monitoring("GET", endpoint): + # We only need the FEN, but it seems that the Lichess "game by ID" API endpoints + # can only return the full PGN - which will require a bit more work to parse. + response = await api_client.get(endpoint, params={"pgnInJson": "true"}) - return msgspec.json.decode(response.content, type=LichessGameExport) + response_content = response.content + cache.set(cache_key, response_content, _GET_GAME_BY_ID_CACHE["DURATION"]) + + return msgspec.json.decode(response_content, type=LichessGameExport) async def create_correspondence_game( @@ -105,10 +145,17 @@ def _lichess_api_monitoring(target_endpoint, method) -> "Iterator[None]": # This is the function we'll mock during tests - as it's private, we don't have to # mind about it being directly imported by other modules when we mock it. def _create_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncClient: - return httpx.AsyncClient( + client = httpx.AsyncClient( base_url=settings.LICHESS_HOST, headers={ "Authorization": f"Bearer {access_token}", "Accept": "application/json", }, ) + + # We store the access token in the client object, so we can access it later + # Not super clean, but... this is a side project, and where's the joy if I cannot + # allow myself some dirty shortcuts in such a context? 😄 + client.lichess_access_token = access_token # type: ignore[attr-defined] + + return client diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 2e11ec8..3ac0cf9 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -1,9 +1,11 @@ import io from functools import cached_property from typing import TYPE_CHECKING, NamedTuple, Self, cast +from urllib.parse import urlencode import chess import chess.pgn +from django.urls import reverse from apps.chess.presenters import GamePresenter, GamePresenterUrls @@ -79,7 +81,15 @@ def from_game_data( class LichessCorrespondenceGamePresenter(GamePresenter): - def __init__(self, game_data: "LichessGameExport", my_player_id: "LichessPlayerId"): + def __init__( + self, + *, + game_data: "LichessGameExport", + my_player_id: "LichessPlayerId", + refresh_last_move: bool, + is_htmx_request: bool, + selected_piece_square: "Square | None" = None, + ): self._my_player_id = my_player_id self._game_data = game_data @@ -93,12 +103,14 @@ def __init__(self, game_data: "LichessGameExport", my_player_id: "LichessPlayerI self._chess_board, self.factions ) + # TODO: handle `last_move` super().__init__( fen=fen, piece_role_by_square=piece_role_by_square, teams=teams, - refresh_last_move=True, - is_htmx_request=False, + refresh_last_move=refresh_last_move, + is_htmx_request=is_htmx_request, + selected_piece_square=selected_piece_square, ) @cached_property @@ -200,7 +212,19 @@ def htmx_game_no_selection_url(self, *, board_id: str) -> str: return "#" # TODO def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: - return "#" # TODO + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_select_piece", + kwargs={ + "game_id": self._game_presenter.game_id, + "location": square, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: return "#" # TODO diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index fce5317..1826bee 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -20,6 +20,7 @@ def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): async def test_lichess_homepage_with_access_token_smoke_test( async_client: "DjangoAsyncClient", + cleared_django_default_cache, ): """Just a quick smoke test for now""" @@ -48,6 +49,9 @@ def content(self) -> str: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) + def __init__(self): + self.lichess_access_token = access_token + async def get(self, path, **kwargs): # The client's `get` method is async assert path.startswith("/api/") @@ -133,6 +137,7 @@ async def test_lichess_correspondence_game_without_access_token_should_redirect( async def test_lichess_correspondence_game_with_access_token_smoke_test( async_client: "DjangoAsyncClient", + cleared_django_default_cache, ): """Just a quick smoke test for now""" @@ -161,6 +166,9 @@ def content(self) -> str: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) + def __init__(self): + self.lichess_access_token = access_token + async def get(self, path, **kwargs): # The client's `get` method is async assert path.startswith(("/api/", "/game/export/")) diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index d28a22f..497ee0c 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -17,6 +17,16 @@ views.lichess_correspondence_game, name="correspondence_game", ), + # path( + # "htmx/games/correspondence//no-selection/", + # views.htmx_game_no_selection, + # name="htmx_game_no_selection", + # ), + path( + "htmx/games/correspondence//pieces//select/", + views.htmx_game_select_piece, + name="htmx_game_select_piece", + ), # OAuth2 Views: path( "oauth2/start-flow/", diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index d7f4101..8b0cb14 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -17,8 +17,11 @@ get_lichess_token_retrieval_via_oauth2_process_starting_url, ) from .components.pages import lichess_pages as lichess_pages +from .components.pages.lichess_pages import lichess_game_moving_parts_fragment from .forms import LichessCorrespondenceGameCreationForm +from .presenters import LichessCorrespondenceGamePresenter from .views_decorators import ( + handle_chess_logic_exceptions, redirect_if_no_lichess_access_token, with_lichess_access_token, ) @@ -26,7 +29,14 @@ if TYPE_CHECKING: from django.http import HttpRequest - from .models import LichessAccessToken, LichessGameId + from apps.chess.types import Square + + from .models import ( + LichessAccessToken, + LichessAccountInformation, + LichessGameExport, + LichessGameId, + ) # TODO: use Django message framework for everything that happens outside of the chess # board, so we can notify users of what's going on @@ -101,29 +111,47 @@ async def lichess_correspondence_game( lichess_access_token: "LichessAccessToken", game_id: "LichessGameId", ) -> HttpResponse: - async with lichess_api.get_lichess_api_client( - access_token=lichess_access_token - ) as lichess_api_client: - # As the queries are unrelated, let's run them in parallel: - async with asyncio.TaskGroup() as tg: - me = tg.create_task( - lichess_api.get_my_account(api_client=lichess_api_client) - ) - game_data = tg.create_task( - lichess_api.get_game_by_id( - api_client=lichess_api_client, game_id=game_id - ) - ) + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + my_player_id=me.id, + refresh_last_move=True, + is_htmx_request=False, + ) return HttpResponse( lichess_pages.lichess_correspondence_game_page( request=request, - me=me.result(), - game_data=game_data.result(), + game_presenter=game_presenter, ) ) +@require_safe +@with_lichess_access_token +@redirect_if_no_lichess_access_token +@handle_chess_logic_exceptions +async def htmx_game_select_piece( + request: "HttpRequest", + *, + lichess_access_token: "LichessAccessToken", + game_id: "LichessGameId", + location: "Square", +) -> HttpResponse: + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + my_player_id=me.id, + selected_piece_square=location, + is_htmx_request=True, + refresh_last_move=False, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + @require_POST def lichess_redirect_to_oauth2_flow_starting_url( request: "HttpRequest", @@ -191,3 +219,36 @@ def lichess_detach_account(request: "HttpRequest") -> HttpResponse: cookie_helpers.delete_lichess_api_access_token_from_cookies(response=response) return response + + +def _lichess_game_moving_parts_fragment_response( + *, + game_presenter: LichessCorrespondenceGamePresenter, + request: "HttpRequest", + board_id: str, +) -> HttpResponse: + return HttpResponse( + lichess_game_moving_parts_fragment( + game_presenter=game_presenter, request=request, board_id=board_id + ), + ) + + +async def _get_game_context_from_lichess( + lichess_access_token: "LichessAccessToken", game_id: "LichessGameId" +) -> tuple["LichessAccountInformation", "LichessGameExport"]: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # As the queries are unrelated, let's run them in parallel: + async with asyncio.TaskGroup() as tg: + me = tg.create_task( + lichess_api.get_my_account(api_client=lichess_api_client) + ) + game_data = tg.create_task( + lichess_api.get_game_by_id( + api_client=lichess_api_client, game_id=game_id + ) + ) + + return me.result(), game_data.result() diff --git a/src/apps/lichess_bridge/views_decorators.py b/src/apps/lichess_bridge/views_decorators.py index 7e6885a..3d86a54 100644 --- a/src/apps/lichess_bridge/views_decorators.py +++ b/src/apps/lichess_bridge/views_decorators.py @@ -2,8 +2,10 @@ from typing import TYPE_CHECKING from asgiref.sync import iscoroutinefunction +from django.core.exceptions import BadRequest from django.shortcuts import redirect +from ..chess.types import ChessLogicException from . import cookie_helpers if TYPE_CHECKING: @@ -70,3 +72,25 @@ def wrapper( ) return wrapper + + +def handle_chess_logic_exceptions(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except ChessLogicException as exc: + raise BadRequest(str(exc)) from exc + + else: + + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except ChessLogicException as exc: + raise BadRequest(str(exc)) from exc + + return wrapper From 71b89d5bc098b3e06b5827fa98583c7286364fb2 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Mon, 9 Sep 2024 21:56:35 +0100 Subject: [PATCH 12/42] [chore] Use Uvicorn's logger in our app when using the "development" settings --- src/project/settings/development.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/project/settings/development.py b/src/project/settings/development.py index 72fe4f6..26ec4ce 100644 --- a/src/project/settings/development.py +++ b/src/project/settings/development.py @@ -21,9 +21,17 @@ LOGGING = { "version": 1, "disable_existing_loggers": False, + "formatters": { + "uvicorn": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s [%(name)s] %(message)s", + "use_colors": True, + }, + }, "handlers": { "console": { "class": "logging.StreamHandler", + "formatter": "uvicorn", }, }, "root": { From 0546a7903ba67650f2c8873328f22779caf402e4 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 10 Sep 2024 12:44:11 +0100 Subject: [PATCH 13/42] [lichess] We can now move one our pieces, via HTMX --- src/apps/daily_challenge/presenters.py | 2 +- src/apps/lichess_bridge/lichess_api.py | 54 ++++++++++-- src/apps/lichess_bridge/models.py | 93 ++++++++++++++++++++- src/apps/lichess_bridge/presenters.py | 92 ++++++-------------- src/apps/lichess_bridge/tests/test_views.py | 9 +- src/apps/lichess_bridge/urls.py | 5 ++ src/apps/lichess_bridge/views.py | 74 ++++++++++++++-- 7 files changed, 246 insertions(+), 83 deletions(-) diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index c65b627..7dfa61f 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -204,7 +204,7 @@ def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: ) def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: - assert self._game_presenter.selected_piece is not None + assert self._game_presenter.selected_piece is not None # type checker: happy return "".join( ( reverse( diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 54c620f..01fdfee 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -3,6 +3,7 @@ import logging import time from typing import TYPE_CHECKING +from zlib import adler32 import httpx import msgspec @@ -19,18 +20,20 @@ if TYPE_CHECKING: from collections.abc import Iterator - from .models import LichessAccessToken, LichessGameSeekId + from apps.chess.types import Square + + from .models import LichessAccessToken, LichessGameId, LichessGameSeekId _logger = logging.getLogger(__name__) _GET_MY_ACCOUNT_CACHE = { - "KEY_PATTERN": "lichess_bridge::get_my_account::{lichess_access_token}", + "KEY_PATTERN": "lichess_bridge::get_my_account::{lichess_access_token_hash}", "DURATION": dt.timedelta(seconds=120).total_seconds(), } _GET_GAME_BY_ID_CACHE = { - "KEY_PATTERN": "lichess_bridge::get_game_by_id::{lichess_access_token}::{game_id}", + "KEY_PATTERN": "lichess_bridge::get_game_by_id::{game_id}", "DURATION": dt.timedelta(seconds=30).total_seconds(), } @@ -43,8 +46,14 @@ async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInfo """ This is cached for a short amount of time. """ + # Let's not expose any access tokens in our cache keys, + # and use a quick hash of them instead. + # "An Adler-32 checksum is almost as reliable as a CRC32 but can be computed much more quickly." + # --> should be enough for our case :-) + lichess_access_token_hash = adler32(api_client.lichess_access_token.encode()) # type: ignore[attr-defined] + cache_key = _GET_MY_ACCOUNT_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] - lichess_access_token=api_client.lichess_access_token # type: ignore[attr-defined] + lichess_access_token_hash=lichess_access_token_hash ) if cached_data := cache.get(cache_key): _logger.info("Using cached data for 'get_my_account'.") @@ -54,6 +63,7 @@ async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInfo endpoint = "/api/account" with _lichess_api_monitoring("GET", endpoint): response = await api_client.get(endpoint) + response.raise_for_status() response_content = response.content cache.set(cache_key, response_content, _GET_MY_ACCOUNT_CACHE["DURATION"]) @@ -70,6 +80,7 @@ async def get_my_ongoing_games( endpoint = "/api/account/playing" with _lichess_api_monitoring("GET", endpoint): response = await api_client.get(endpoint, params={"nb": count}) + response.raise_for_status() class ResponseDataWrapper(msgspec.Struct): """The ongoing games are wrapped in a "nowPlaying" root object's key""" @@ -80,14 +91,13 @@ class ResponseDataWrapper(msgspec.Struct): async def get_game_by_id( - *, api_client: httpx.AsyncClient, game_id: str + *, api_client: httpx.AsyncClient, game_id: "LichessGameId" ) -> LichessGameExport: """ This is cached for a short amount of time, so we don't re-fetch the same games again while the player is selecting pieces. """ cache_key = _GET_GAME_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] - lichess_access_token=api_client.lichess_access_token, # type: ignore[attr-defined] game_id=game_id, ) if cached_data := cache.get(cache_key): @@ -100,6 +110,7 @@ async def get_game_by_id( # We only need the FEN, but it seems that the Lichess "game by ID" API endpoints # can only return the full PGN - which will require a bit more work to parse. response = await api_client.get(endpoint, params={"pgnInJson": "true"}) + response.raise_for_status() response_content = response.content cache.set(cache_key, response_content, _GET_GAME_BY_ID_CACHE["DURATION"]) @@ -107,6 +118,35 @@ async def get_game_by_id( return msgspec.json.decode(response_content, type=LichessGameExport) +async def move_lichess_game_piece( + *, + api_client: httpx.AsyncClient, + game_id: "LichessGameId", + from_: "Square", + to: "Square", + offering_draw: bool = False, +) -> bool: + """ + Calling this function will make a move in a Lichess game. + As a side effect, it will also clear the `get_game_by_id` cache for that game. + """ + # https://lichess.org/api#tag/Board/operation/boardGameMove + move_uci = f"{from_}{to}" + endpoint = f"/api/board/game/{game_id}/move/{move_uci}" + with _lichess_api_monitoring("POST", endpoint): + response = await api_client.post( + endpoint, params={"offeringDraw": "true"} if offering_draw else None + ) + response.raise_for_status() + + get_game_by_id_cache_key = _GET_GAME_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + cache.delete(get_game_by_id_cache_key) + + return response.json()["ok"] + + async def create_correspondence_game( *, api_client: httpx.AsyncClient, days_per_turn: int ) -> "LichessGameSeekId": @@ -123,6 +163,8 @@ async def create_correspondence_game( "color": "random", }, ) + response.raise_for_status() + return str(response.json()["id"]) diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index fc43e82..a95b2fd 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -1,10 +1,19 @@ -from typing import Literal, TypeAlias +import dataclasses +import functools +import io +from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias +import chess.pgn import msgspec from django.db import models from apps.chess.types import FEN # used by msgspec, so it has to be a "real" import +if TYPE_CHECKING: + import chess + + from apps.chess.types import PlayerSide + LichessAccessToken: TypeAlias = str # e.g. "lio_6EeGimHMalSVH9qMcfUc2JJ3xdBPlqrL" LichessPlayerId: TypeAlias = str # e.g. "dunsap" @@ -77,6 +86,13 @@ ] +# Presenters are the objects we pass to our templates. +_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict["LichessPlayerSide", "PlayerSide"] = { + "white": "w", + "black": "b", +} + + class LichessCorrespondenceGameDaysChoice(models.IntegerChoices): # https://lichess.org/api#tag/Board/operation/apiBoardSeek ONE_DAY = (1, "1 days") @@ -171,3 +187,78 @@ class LichessGameExport(msgspec.Struct): pgn: str daysPerTurn: int division: dict # ??? + + +class LichessGameExportMetadataPlayer(NamedTuple): + id: LichessPlayerId + username: LichessGameFullId + player_side: "PlayerSide" + + +class LichessGameExportMetadataPlayers(NamedTuple): + """ + Information about the players of a game, structured in a "me" and "them" way, and + giving us the "active player" as well. + + (as opposed to the "players" field in the LichessGameExport class, which tells us + who the "white" and "black" players are but without telling us which one is "me", + or which one is the current active player) + """ + + me: LichessGameExportMetadataPlayer + them: LichessGameExportMetadataPlayer + active_player: Literal["me", "them"] + + +@dataclasses.dataclass +class LichessGameExportWithMetadata: + """ + Wraps a LichessGameExport object with some additional metadata related to the + current player. + """ + + game_export: LichessGameExport + my_player_id: "LichessPlayerId" + + @functools.cached_property + def chess_board(self) -> "chess.Board": + pgn_game = chess.pgn.read_game(io.StringIO(self.game_export.pgn)) + if not pgn_game: + raise ValueError("Could not read PGN game") + return pgn_game.end().board() + + @functools.cached_property + def active_player_side(self) -> "LichessPlayerSide": + return "white" if self.chess_board.turn else "black" + + @functools.cached_property + def players_from_my_perspective(self) -> "LichessGameExportMetadataPlayers": + my_side: "LichessPlayerSide" = ( + "white" + if self.game_export.players.white.user.id == self.my_player_id + else "black" + ) + their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + + my_player: "LichessGameUser" = getattr(self.game_export.players, my_side).user + their_player: "LichessGameUser" = getattr( + self.game_export.players, their_side + ).user + + result = LichessGameExportMetadataPlayers( + me=LichessGameExportMetadataPlayer( + id=my_player.id, + username=my_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], + ), + them=LichessGameExportMetadataPlayer( + id=their_player.id, + username=their_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], + ), + active_player="me" if self.active_player_side == my_side else "them", + ) + + print("***** Computed value for 'get_players_from_my_perspective'.") + + return result diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 3ac0cf9..8fe409a 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -1,6 +1,5 @@ -import io from functools import cached_property -from typing import TYPE_CHECKING, NamedTuple, Self, cast +from typing import TYPE_CHECKING, cast from urllib.parse import urlencode import chess @@ -30,80 +29,28 @@ TeamMember, ) - from .models import ( - LichessGameExport, - LichessGameUser, - LichessPlayerFullId, - LichessPlayerId, - LichessPlayerSide, - ) - -# Presenters are the objects we pass to our templates. -_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict["LichessPlayerSide", "PlayerSide"] = { - "white": "w", - "black": "b", -} - - -class _LichessGamePlayer(NamedTuple): - id: "LichessPlayerId" - username: "LichessPlayerFullId" - player_side: "PlayerSide" - - -class _LichessGamePlayers(NamedTuple): - me: _LichessGamePlayer - them: _LichessGamePlayer - - @classmethod - def from_game_data( - cls, game_data: "LichessGameExport", my_player_id: "LichessPlayerId" - ) -> Self: - my_side: "LichessPlayerSide" = ( - "white" if game_data.players.white.user.id == my_player_id else "black" - ) - their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" - my_player: "LichessGameUser" = getattr(game_data.players, my_side).user - their_player: "LichessGameUser" = getattr(game_data.players, their_side).user - - return cls( - me=_LichessGamePlayer( - id=my_player.id, - username=my_player.name, - player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], - ), - them=_LichessGamePlayer( - id=their_player.id, - username=their_player.name, - player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], - ), - ) + from .models import LichessGameExportWithMetadata class LichessCorrespondenceGamePresenter(GamePresenter): def __init__( self, *, - game_data: "LichessGameExport", - my_player_id: "LichessPlayerId", + game_data: "LichessGameExportWithMetadata", refresh_last_move: bool, is_htmx_request: bool, selected_piece_square: "Square | None" = None, + last_move: tuple["Square", "Square"] | None = None, ): - self._my_player_id = my_player_id self._game_data = game_data - pgn_game = chess.pgn.read_game(io.StringIO(game_data.pgn)) - if not pgn_game: - raise ValueError("Could not read PGN game") - self._chess_board = pgn_game.end().board() + self._chess_board = game_data.chess_board fen = cast("FEN", self._chess_board.fen()) teams, piece_role_by_square = self._create_teams_and_piece_role_by_square( self._chess_board, self.factions ) - # TODO: handle `last_move` super().__init__( fen=fen, piece_role_by_square=piece_role_by_square, @@ -111,6 +58,7 @@ def __init__( refresh_last_move=refresh_last_move, is_htmx_request=is_htmx_request, selected_piece_square=selected_piece_square, + last_move=last_move, ) @cached_property @@ -139,13 +87,15 @@ def solution_index(self) -> int | None: @cached_property def game_id(self) -> str: - return self._game_data.id + return self._game_data.game_export.id @cached_property def factions(self) -> "Factions": + players = self._game_data.players_from_my_perspective + return { - self._players.me.player_side: "humans", - self._players.them.player_side: "undeads", + players.me.player_side: "humans", + players.them.player_side: "undeads", } @property @@ -160,10 +110,6 @@ def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": def speech_bubble(self) -> "SpeechBubbleData | None": return None - @cached_property - def _players(self) -> _LichessGamePlayers: - return _LichessGamePlayers.from_game_data(self._game_data, self._my_player_id) - @staticmethod def _create_teams_and_piece_role_by_square( chess_board: "chess.Board", factions: "Factions" @@ -227,7 +173,21 @@ def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: ) def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: - return "#" # TODO + assert self._game_presenter.selected_piece is not None # type checker: happy + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_move_piece", + kwargs={ + "game_id": self._game_presenter.game_id, + "from_": self._game_presenter.selected_piece.square, + "to": square, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) def htmx_game_play_bot_move_url(self, *, board_id: str) -> str: return "#" # TODO diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 1826bee..93551b1 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -49,6 +49,9 @@ def content(self) -> str: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) + def raise_for_status(self): + pass + def __init__(self): self.lichess_access_token = access_token @@ -166,6 +169,9 @@ def content(self) -> str: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) + def raise_for_status(self): + pass + def __init__(self): self.lichess_access_token = access_token @@ -177,8 +183,9 @@ async def get(self, path, **kwargs): with mock.patch( "apps.lichess_bridge.lichess_api._create_lichess_api_client", ) as create_lichess_api_client_mock: + client_mock = HttpClientMock() create_lichess_api_client_mock.return_value.__aenter__.return_value = ( - HttpClientMock() + client_mock ) response = await async_client.get("/lichess/games/correspondence/tFfGsEpb/") diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index 497ee0c..281156d 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -27,6 +27,11 @@ views.htmx_game_select_piece, name="htmx_game_select_piece", ), + path( + "htmx/games/correspondence//pieces//move//", + views.htmx_game_move_piece, + name="htmx_game_move_piece", + ), # OAuth2 Views: path( "oauth2/start-flow/", diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 8b0cb14..34b1855 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -19,6 +19,7 @@ from .components.pages import lichess_pages as lichess_pages from .components.pages.lichess_pages import lichess_game_moving_parts_fragment from .forms import LichessCorrespondenceGameCreationForm +from .models import LichessGameExportWithMetadata from .presenters import LichessCorrespondenceGamePresenter from .views_decorators import ( handle_chess_logic_exceptions, @@ -29,12 +30,11 @@ if TYPE_CHECKING: from django.http import HttpRequest - from apps.chess.types import Square + from apps.chess.types import ChessInvalidMoveException, Square from .models import ( LichessAccessToken, LichessAccountInformation, - LichessGameExport, LichessGameId, ) @@ -114,7 +114,6 @@ async def lichess_correspondence_game( me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( game_data=game_data, - my_player_id=me.id, refresh_last_move=True, is_htmx_request=False, ) @@ -141,7 +140,6 @@ async def htmx_game_select_piece( me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( game_data=game_data, - my_player_id=me.id, selected_piece_square=location, is_htmx_request=True, refresh_last_move=False, @@ -152,6 +150,64 @@ async def htmx_game_select_piece( ) +@require_POST +@with_lichess_access_token +@redirect_if_no_lichess_access_token +@handle_chess_logic_exceptions +async def htmx_game_move_piece( + request: "HttpRequest", + *, + lichess_access_token: "LichessAccessToken", + game_id: "LichessGameId", + from_: "Square", + to: "Square", +) -> HttpResponse: + if from_ == to: + raise ChessInvalidMoveException("Not a move") + + # game_over_already = ctx.game_state.game_over != PlayerGameOverState.PLAYING + # + # if ctx.game_state.game_over != PlayerGameOverState.PLAYING: + # raise ChessInvalidActionException("Game is over, cannot move pieces") + + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + is_my_turn = game_data.players_from_my_perspective.active_player == "me" + if not is_my_turn: + raise ChessInvalidMoveException("Not my turn") + + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + move_was_successful = await lichess_api.move_lichess_game_piece( + api_client=lichess_api_client, game_id=game_id, from_=from_, to=to + ) + if not move_was_successful: + raise ChessInvalidMoveException( + f"Move '{from_}{to}' on game '{game_id}' was not successful on Lichess' side" + ) + # The move was successful, let's re-fetch the updated game state: + # (the cache for this game's data has be cleared by `move_lichess_game_piece`) + game_export = await lichess_api.get_game_by_id( + api_client=lichess_api_client, game_id=game_id + ) + + # TODO: handle end of game after move! + + game_data = LichessGameExportWithMetadata( + game_export=game_export, my_player_id=me.id + ) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + last_move=(from_, to), + is_htmx_request=True, + refresh_last_move=True, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + @require_POST def lichess_redirect_to_oauth2_flow_starting_url( request: "HttpRequest", @@ -236,19 +292,21 @@ def _lichess_game_moving_parts_fragment_response( async def _get_game_context_from_lichess( lichess_access_token: "LichessAccessToken", game_id: "LichessGameId" -) -> tuple["LichessAccountInformation", "LichessGameExport"]: +) -> tuple["LichessAccountInformation", "LichessGameExportWithMetadata"]: async with lichess_api.get_lichess_api_client( access_token=lichess_access_token ) as lichess_api_client: # As the queries are unrelated, let's run them in parallel: async with asyncio.TaskGroup() as tg: - me = tg.create_task( + me_task = tg.create_task( lichess_api.get_my_account(api_client=lichess_api_client) ) - game_data = tg.create_task( + game_data_task = tg.create_task( lichess_api.get_game_by_id( api_client=lichess_api_client, game_id=game_id ) ) - return me.result(), game_data.result() + me, game_data = me_task.result(), game_data_task.result() + + return me, LichessGameExportWithMetadata(game_export=game_data, my_player_id=me.id) From 271219b92ff0a8cab370ec42c46ffe24631910d5 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 10 Sep 2024 14:59:25 +0100 Subject: [PATCH 14/42] [lichess] Make sure we cannot play opponent's pieces during their turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That wouldn't be very fair, right? 😅 --- src/apps/chess/components/chess_board.py | 3 ++- src/apps/chess/presenters.py | 4 ---- .../daily_challenge/business_logic/_get_speech_bubble.py | 2 +- src/apps/daily_challenge/presenters.py | 8 ++------ src/apps/lichess_bridge/presenters.py | 8 ++------ 5 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 733bac2..0acf229 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -303,7 +303,7 @@ def chess_piece( piece_can_be_moved_by_player = ( game_presenter.solution_index is not None - and game_presenter.is_player_turn + and game_presenter.is_my_turn and square in game_presenter.squares_with_pieces_that_can_move ) unit_display = chess_character_display( @@ -409,6 +409,7 @@ def chess_available_target( assert game_presenter.selected_piece is not None can_move = ( not game_presenter.is_game_over + and game_presenter.is_my_turn and game_presenter.active_player_side == piece_player_side ) bg_class = ( diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index dbb31b4..8a894a3 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -160,10 +160,6 @@ def active_player_side(self) -> "PlayerSide": def can_select_pieces(self) -> bool: return True - @property - @abstractmethod - def is_player_turn(self) -> bool: ... - @property @abstractmethod def is_bot_turn(self) -> bool: ... diff --git a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py index df618ee..7651ba8 100644 --- a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py +++ b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py @@ -130,7 +130,7 @@ def get_speech_bubble( ) if ( - game_presenter.is_player_turn + game_presenter.is_my_turn and game_presenter.is_htmx_request and not game_presenter.selected_piece and game_presenter.naive_score < -3 diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 7dfa61f..81fbcad 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -73,7 +73,7 @@ def urls(self) -> "DailyChallengeGamePresenterUrls": @cached_property def is_my_turn(self) -> bool: - return self._challenge.my_side == self.active_player + return not self.is_bot_turn @cached_property def challenge_current_attempt_turns_counter(self) -> int: @@ -114,11 +114,7 @@ def game_phase(self) -> "GamePhase": def can_select_pieces(self) -> bool: # During the bot's turn we're not allowed to select any piece, as we're waiting # for the delayed HTMX request to play the bot's move. - return self.is_player_turn and not self.is_game_over - - @cached_property - def is_player_turn(self) -> bool: - return self.active_player_side != self._challenge.bot_side + return self.is_my_turn and not self.is_game_over @cached_property def is_bot_turn(self) -> bool: diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 8fe409a..e54795c 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -67,19 +67,15 @@ def urls(self) -> "GamePresenterUrls": @cached_property def is_my_turn(self) -> bool: - return True # TODO + return self._game_data.players_from_my_perspective.active_player == "me" @cached_property def game_phase(self) -> "GamePhase": return "waiting_for_player_selection" # TODO - @cached_property - def is_player_turn(self) -> bool: - return True # TODO - @cached_property def is_bot_turn(self) -> bool: - return False + return False # no bots involved in Lichess correspondence games @property def solution_index(self) -> int | None: From a5b33f0ae497bd8b725b4f08dda139befaf0792a Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Tue, 10 Sep 2024 16:04:58 +0100 Subject: [PATCH 15/42] [lichess] Flip the chess board the other way if we're playing Black --- src/apps/chess/components/chess_board.py | 144 +++++++++++++++------ src/apps/chess/components/chess_helpers.py | 98 ++++++++++---- src/apps/chess/presenters.py | 5 + src/apps/chess/types.py | 6 +- src/apps/daily_challenge/presenters.py | 13 +- src/apps/lichess_bridge/models.py | 2 - src/apps/lichess_bridge/presenters.py | 9 ++ 7 files changed, 207 insertions(+), 70 deletions(-) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 0acf229..32a5ffb 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -19,7 +19,8 @@ from .chess_helpers import ( chess_unit_symbol_class, piece_character_classes, - square_to_piece_tailwind_classes, + piece_should_face_left, + square_to_positioning_tailwind_classes, ) from .misc_ui import speech_bubble_container @@ -29,7 +30,14 @@ from dominate.tags import dom_tag from ..presenters import GamePresenter - from ..types import Factions, PieceRole, PieceType, PlayerSide, Square + from ..types import ( + BoardOrientation, + Factions, + PieceRole, + PieceType, + PlayerSide, + Square, + ) SQUARE_COLOR_TAILWIND_CLASSES = ("bg-chess-square-dark", "bg-chess-square-light") @@ -52,7 +60,8 @@ "character": "z-20", } - +# TODO: get rid of _PLAY_BOT_JS_TEMPLATE and _PLAY_SOLUTION_JS_TEMPLATE, and implement +# this as Web Components we return in the HTMX response. # We'll wait that amount of milliseconds before starting the bot move's calculation: _BOT_MOVE_DELAY = 700 _BOT_MOVE_DELAY_FIRST_TURN_OF_THE_DAY = 1_400 @@ -177,14 +186,27 @@ def chess_board(*, game_presenter: "GamePresenter", board_id: str) -> "dom_tag": game_presenter.force_square_info or game_presenter.is_preview ) squares: list[dom_tag] = [] - for file in FILE_NAMES: - for rank in RANK_NAMES: - squares.append( - chess_board_square( - cast("Square", f"{file}{rank}"), - force_square_info=force_square_info, - ) - ) + match game_presenter.board_orientation: + case "1->8": + for file in FILE_NAMES: + for rank in RANK_NAMES: + squares.append( + chess_board_square( + game_presenter.board_orientation, + cast("Square", f"{file}{rank}"), + force_square_info=force_square_info, + ) + ) + case "8->1": + for file in reversed(FILE_NAMES): + for rank in reversed(RANK_NAMES): + squares.append( + chess_board_square( + game_presenter.board_orientation, + cast("Square", f"{file}{rank}"), + force_square_info=force_square_info, + ) + ) squares_container_classes: list[str] = [ "relative", @@ -253,7 +275,10 @@ def chess_pieces( @cache def chess_board_square( - square: "Square", *, force_square_info: bool = False + board_orientation: "BoardOrientation", + square: "Square", + *, + force_square_info: bool = False, ) -> "dom_tag": file, rank = file_and_rank_from_square(square) square_index = FILE_NAMES.index(file) + RANK_NAMES.index(rank) @@ -263,23 +288,29 @@ def chess_board_square( "aspect-square", "w-1/8", square_color_cls, - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes(board_orientation, square), ] - display_square_info = force_square_info or (rank == "1" or file == "a") - if display_square_info: - square_name = ( - f"{file}{rank}" - if force_square_info - else "".join((file if rank == "1" else "", rank if file == "a" else "")) - ) - square_info = ( - span( - square_name, - cls="text-chess-square-square-info select-none", - ) - if display_square_info - else "" + displayed_file, displayed_rank = None, None + if force_square_info: + displayed_file, displayed_rank = file, rank + else: + match board_orientation: + case "1->8": + if rank == "1": + displayed_file = file + if file == "a": + displayed_rank = rank + case "8->1": + if rank == "8": + displayed_file = file + if file == "h": + displayed_rank = rank + if displayed_file or displayed_rank: + square_name = f"{displayed_file or ''}{displayed_rank or ''}" + square_info = span( + square_name, + cls="text-chess-square-square-info select-none", ) else: square_info = "" @@ -310,7 +341,7 @@ def chess_piece( piece_role=piece_role, game_presenter=game_presenter, square=square ) unit_chess_symbol_display = chess_unit_symbol_display( - piece_role=piece_role, square=square + board_orientation=game_presenter.board_orientation, piece_role=piece_role ) ground_marker = chess_unit_ground_marker( player_side=player_side, can_move=piece_can_be_moved_by_player @@ -331,7 +362,9 @@ def chess_piece( "absolute", "aspect-square", "w-1/8", - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes( + game_presenter.board_orientation, square + ), "cursor-pointer" if not is_game_over else "cursor-default", "pointer-events-auto" if not is_game_over else "pointer-events-none", # Transition-related classes: @@ -430,7 +463,9 @@ def chess_available_target( "aspect-square", "w-1/8", "block", - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes( + game_presenter.board_orientation, square + ), ] additional_attributes = {} @@ -467,6 +502,7 @@ def chess_character_display( square: "Square | None" = None, additional_classes: "Sequence[str]|None" = None, factions: "Factions | None" = None, + board_orientation: "BoardOrientation" = "1->8", ) -> "dom_tag": assert ( game_presenter or factions @@ -496,7 +532,13 @@ def chess_character_display( ): is_potential_capture = True - is_w_side = piece_player_side == "w" + if game_presenter: + board_orientation = game_presenter.board_orientation + is_from_original_left_hand_side = ( + piece_player_side == "w" + if board_orientation == "1->8" + else piece_player_side == "b" + ) piece_type: "PieceType" = type_from_piece_role(piece_role) is_knight, is_king = piece_type == "n", piece_type == "k" @@ -519,9 +561,13 @@ def chess_character_display( is_potential_capture = True # let's highlight checks in "see solution" mode horizontal_translation = ( - ("left-3" if is_knight else "left-0") if is_w_side else "right-0" + ("left-3" if is_knight else "left-0") + if is_from_original_left_hand_side + else "right-0" + ) + vertical_translation = ( + "top-2" if is_knight and is_from_original_left_hand_side else "top-1" ) - vertical_translation = "top-2" if is_knight and is_w_side else "top-1" game_factions = cast("Factions", factions or game_presenter.factions) # type: ignore @@ -534,7 +580,11 @@ def chess_character_display( _CHESS_PIECE_Z_INDEXES["character"], horizontal_translation, vertical_translation, - *piece_character_classes(piece_role=piece_role, factions=game_factions), + *piece_character_classes( + board_orientation=board_orientation, + piece_role=piece_role, + factions=game_factions, + ), # Conditional classes: ( ( @@ -604,7 +654,7 @@ def chess_unit_display_with_ground_marker( def chess_unit_symbol_display( - *, piece_role: "PieceRole", square: "Square" + *, board_orientation: "BoardOrientation", piece_role: "PieceRole" ) -> "dom_tag": player_side = player_side_from_piece_role(piece_role) piece_type = type_from_piece_role(piece_role) @@ -630,13 +680,14 @@ def chess_unit_symbol_display( cls=" ".join(symbol_class), ) + should_face_left = piece_should_face_left(board_orientation, player_side) symbol_display_container_classes = ( "absolute", "top-0", - "left-0" if player_side == "w" else "right-0", + "right-0" if should_face_left else "left-0", _CHESS_PIECE_Z_INDEXES["symbol"], # Quick custom display for white knights, so they face the inside of the board: - "-scale-x-100" if player_side == "w" and is_knight else "", + "-scale-x-100" if is_knight and not should_face_left else "", ) return div( @@ -653,8 +704,16 @@ def chess_last_move( if last_move := game_presenter.last_move: children.extend( [ - chess_last_move_marker(square=last_move[0], move_part="from"), - chess_last_move_marker(square=last_move[1], move_part="to"), + chess_last_move_marker( + board_orientation=game_presenter.board_orientation, + square=last_move[0], + move_part="from", + ), + chess_last_move_marker( + board_orientation=game_presenter.board_orientation, + square=last_move[1], + move_part="to", + ), ] ) @@ -669,7 +728,10 @@ def chess_last_move( def chess_last_move_marker( - *, square: "Square", move_part: Literal["from", "to"] + *, + board_orientation: "BoardOrientation", + square: "Square", + move_part: Literal["from", "to"], ) -> "dom_tag": match move_part: case "from": @@ -709,7 +771,7 @@ def chess_last_move_marker( "aspect-square", "w-1/8", "flex items-center justify-center", - *square_to_piece_tailwind_classes(square), + *square_to_positioning_tailwind_classes(board_orientation, square), ] return div( diff --git a/src/apps/chess/components/chess_helpers.py b/src/apps/chess/components/chess_helpers.py index e84d32e..c23b039 100644 --- a/src/apps/chess/components/chess_helpers.py +++ b/src/apps/chess/components/chess_helpers.py @@ -12,6 +12,7 @@ from collections.abc import Sequence from apps.chess.types import ( + BoardOrientation, Faction, Factions, File, @@ -22,25 +23,53 @@ Square, ) -_PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict["File", str] = { - "a": "translate-y-0/1", - "b": "translate-y-1/1", - "c": "translate-y-2/1", - "d": "translate-y-3/1", - "e": "translate-y-4/1", - "f": "translate-y-5/1", - "g": "translate-y-6/1", - "h": "translate-y-7/1", +_PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict[ + "BoardOrientation", dict["File", str] +] = { + "1->8": { + "a": "translate-y-0/1", + "b": "translate-y-1/1", + "c": "translate-y-2/1", + "d": "translate-y-3/1", + "e": "translate-y-4/1", + "f": "translate-y-5/1", + "g": "translate-y-6/1", + "h": "translate-y-7/1", + }, + "8->1": { + "a": "translate-y-7/1", + "b": "translate-y-6/1", + "c": "translate-y-5/1", + "d": "translate-y-4/1", + "e": "translate-y-3/1", + "f": "translate-y-2/1", + "g": "translate-y-1/1", + "h": "translate-y-0/1", + }, } -_PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict["Rank", str] = { - "1": "translate-x-0/1", - "2": "translate-x-1/1", - "3": "translate-x-2/1", - "4": "translate-x-3/1", - "5": "translate-x-4/1", - "6": "translate-x-5/1", - "7": "translate-x-6/1", - "8": "translate-x-7/1", +_PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict[ + "BoardOrientation", dict["Rank", str] +] = { + "1->8": { + "1": "translate-x-0/1", + "2": "translate-x-1/1", + "3": "translate-x-2/1", + "4": "translate-x-3/1", + "5": "translate-x-4/1", + "6": "translate-x-5/1", + "7": "translate-x-6/1", + "8": "translate-x-7/1", + }, + "8->1": { + "1": "translate-x-7/1", + "2": "translate-x-6/1", + "3": "translate-x-5/1", + "4": "translate-x-4/1", + "5": "translate-x-3/1", + "6": "translate-x-2/1", + "7": "translate-x-1/1", + "8": "translate-x-0/1", + }, } _SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict["File", str] = { @@ -106,11 +135,13 @@ @cache -def square_to_piece_tailwind_classes(square: "Square") -> "Sequence[str]": +def square_to_positioning_tailwind_classes( + board_orientation: "BoardOrientation", square: "Square" +) -> "Sequence[str]": file, rank = file_and_rank_from_square(square) return ( - _PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS[file], - _PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS[rank], + _PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS[board_orientation][file], + _PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS[board_orientation][rank], ) @@ -123,17 +154,34 @@ def square_to_square_center_tailwind_classes(square: "Square") -> "Sequence[str] ) +@cache +def piece_should_face_left( + board_orientation: "BoardOrientation", player_side: "PlayerSide" +) -> bool: + return (board_orientation == "1->8" and player_side == "b") or ( + board_orientation == "8->1" and player_side == "w" + ) + + def piece_character_classes( - *, piece_role: "PieceRole", factions: "Factions" + *, + board_orientation: "BoardOrientation", + piece_role: "PieceRole", + factions: "Factions", ) -> "Sequence[str]": return _piece_character_classes_for_factions( - piece_role=piece_role, factions_tuple=tuple(factions.items()) + board_orientation=board_orientation, + piece_role=piece_role, + factions_tuple=tuple(factions.items()), ) @cache def _piece_character_classes_for_factions( - *, piece_role: "PieceRole", factions_tuple: "tuple[tuple[PlayerSide, Faction], ...]" + *, + board_orientation: "BoardOrientation", + piece_role: "PieceRole", + factions_tuple: "tuple[tuple[PlayerSide, Faction], ...]", ) -> "Sequence[str]": # N.B. We use a tuple here for the factions, so they're hashable and can be used as cached key piece_name = PIECE_TYPE_TO_NAME[type_from_piece_role(piece_role)] @@ -142,7 +190,7 @@ def _piece_character_classes_for_factions( faction = factions_dict[player_side] classes = [_PIECE_UNITS_CLASSES[faction][piece_name]] player_side = player_side_from_piece_role(piece_role) - if player_side == "b": + if piece_should_face_left(board_orientation, player_side): classes.append("-scale-x-100") return classes diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 8a894a3..252cd93 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -24,6 +24,7 @@ from .types import ( FEN, + BoardOrientation, Factions, GamePhase, GameTeams, @@ -107,6 +108,10 @@ def __init__( target_to_confirm=target_to_confirm, ) + @property + @abstractmethod + def board_orientation(self) -> "BoardOrientation": ... + @property @abstractmethod def urls(self) -> "GamePresenterUrls": ... diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index edf9538..edf9e4b 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -29,7 +29,6 @@ PieceName = Literal["pawn", "knight", "bishop", "rook", "queen", "king"] - # fmt: off TeamMemberRole = Literal[ # 8 pawns: @@ -103,6 +102,11 @@ "fifty_moves", ] +BoardOrientation = Literal[ + "1->8", # initial "white" side on the left-hand side + "8->1", # initial "black" side on the left-hand side +] + Faction = Literal[ "humans", diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 81fbcad..76402ea 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -14,7 +14,14 @@ from apps.chess.models import UserPrefs from apps.chess.presenters import SpeechBubbleData - from apps.chess.types import Factions, GamePhase, PieceRole, PlayerSide, Square + from apps.chess.types import ( + BoardOrientation, + Factions, + GamePhase, + PieceRole, + PlayerSide, + Square, + ) from .models import DailyChallenge, PlayerGameState @@ -67,6 +74,10 @@ def __init__( self.is_very_first_game = is_very_first_game self._forced_speech_bubble = forced_speech_bubble + @cached_property + def board_orientation(self) -> "BoardOrientation": + return "1->8" if self._challenge.my_side == "w" else "8->1" + @cached_property def urls(self) -> "DailyChallengeGamePresenterUrls": return DailyChallengeGamePresenterUrls(game_presenter=self) diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index a95b2fd..262e1cf 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -259,6 +259,4 @@ def players_from_my_perspective(self) -> "LichessGameExportMetadataPlayers": active_player="me" if self.active_player_side == my_side else "them", ) - print("***** Computed value for 'get_players_from_my_perspective'.") - return result diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index e54795c..2de6288 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -18,6 +18,7 @@ from apps.chess.presenters import SpeechBubbleData from apps.chess.types import ( FEN, + BoardOrientation, Factions, GamePhase, GameTeams, @@ -61,6 +62,14 @@ def __init__( last_move=last_move, ) + @cached_property + def board_orientation(self) -> "BoardOrientation": + return ( + "1->8" + if self._game_data.players_from_my_perspective.me.player_side == "w" + else "8->1" + ) + @cached_property def urls(self) -> "GamePresenterUrls": return LichessCorrespondenceGamePresenterUrls(game_presenter=self) From 272b936e8458a9c4784d1c046aac429af9f2928a Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 12:44:17 +0100 Subject: [PATCH 16/42] [chess] Use more immutable data structures Because we want to cache the result of many of our chess-related functions, we'd better use immutable data structures (such as NamedTuples) to prevent bugs where we would unintentionally mutate a cached result, used by the whole app. --- src/apps/chess/business_logic/__init__.py | 4 +- .../_calculate_piece_available_targets.py | 2 +- .../business_logic/_compute_game_score.py | 48 ---------- .../chess/business_logic/_do_chess_move.py | 26 ++++-- ...do_chess_move_with_piece_role_by_square.py | 78 ++++++++++++++++ src/apps/chess/chess_helpers.py | 5 +- src/apps/chess/components/chess_board.py | 17 ++-- src/apps/chess/components/chess_helpers.py | 28 ++---- src/apps/chess/consts.py | 2 +- src/apps/chess/models.py | 54 ++++++++++- src/apps/chess/presenters.py | 10 +-- src/apps/chess/types.py | 19 ++-- .../business_logic/__init__.py | 1 + .../business_logic/_get_speech_bubble.py | 9 +- .../_move_daily_challenge_piece.py | 47 ++++------ ..._daily_challenge_teams_and_pieces_roles.py | 49 +++++----- .../components/misc_ui/help.py | 11 +-- .../components/misc_ui/help_modal.py | 2 +- .../components/misc_ui/status_bar.py | 9 +- src/apps/daily_challenge/consts.py | 8 +- src/apps/daily_challenge/models.py | 9 +- src/apps/daily_challenge/presenters.py | 8 +- .../lichess_bridge/business_logic/__init__.py | 9 ++ ...ce_role_by_square_for_starting_position.py | 72 +++++++++++++++ .../_rebuild_game_from_starting_position.py | 59 ++++++++++++ src/apps/lichess_bridge/models.py | 89 ++++++++++++++++--- src/apps/lichess_bridge/presenters.py | 78 +++------------- 27 files changed, 489 insertions(+), 264 deletions(-) delete mode 100644 src/apps/chess/business_logic/_compute_game_score.py create mode 100644 src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py create mode 100644 src/apps/lichess_bridge/business_logic/__init__.py create mode 100644 src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py create mode 100644 src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py diff --git a/src/apps/chess/business_logic/__init__.py b/src/apps/chess/business_logic/__init__.py index f67ade3..f4ae296 100644 --- a/src/apps/chess/business_logic/__init__.py +++ b/src/apps/chess/business_logic/__init__.py @@ -2,5 +2,7 @@ from ._calculate_fen_before_move import calculate_fen_before_move from ._calculate_piece_available_targets import calculate_piece_available_targets -from ._compute_game_score import compute_game_score from ._do_chess_move import do_chess_move +from ._do_chess_move_with_piece_role_by_square import ( + do_chess_move_with_piece_role_by_square, +) diff --git a/src/apps/chess/business_logic/_calculate_piece_available_targets.py b/src/apps/chess/business_logic/_calculate_piece_available_targets.py index 63e4929..bbd7330 100644 --- a/src/apps/chess/business_logic/_calculate_piece_available_targets.py +++ b/src/apps/chess/business_logic/_calculate_piece_available_targets.py @@ -2,7 +2,7 @@ import chess -from apps.chess.chess_helpers import chess_lib_square_to_square +from ..chess_helpers import chess_lib_square_to_square if TYPE_CHECKING: from apps.chess.types import Square diff --git a/src/apps/chess/business_logic/_compute_game_score.py b/src/apps/chess/business_logic/_compute_game_score.py deleted file mode 100644 index c92aab7..0000000 --- a/src/apps/chess/business_logic/_compute_game_score.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -import chess -import chess.engine -from django.conf import settings - -if TYPE_CHECKING: - from ..types import FEN - -_logger = logging.getLogger(__name__) - - -def compute_game_score( - *, chess_board: chess.Board | None = None, fen: "FEN | None" = None -) -> int: - """ - Returns the advantage of the white player, in centipawns. - ⚠ This function is blocking, and it can take up to 0.1 second to return, so it - should only be called from operations taking place in the Django Admin! - """ - assert chess_board or fen, "Either `chess_board` or `fen` must be provided." - - if not chess_board: - chess_board = chess.Board(fen) - - _logger.info( - "Computing game score for FEN: %s", - chess_board.fen(), - extra={ - "Stockfish path": settings.STOCKFISH_PATH, - "Stockfish time limit": settings.STOCKFISH_TIME_LIMIT, - }, - ) - - try: - engine = chess.engine.SimpleEngine.popen_uci(settings.STOCKFISH_PATH) - except chess.engine.EngineTerminatedError as exc: - _logger.error("Stockfish engine terminated before analyze: %s", exc) - return 0 - - info = engine.analyse( - chess_board, chess.engine.Limit(time=settings.STOCKFISH_TIME_LIMIT) - ) - advantage = info["score"].white().score() - engine.quit() - - return advantage or 0 diff --git a/src/apps/chess/business_logic/_do_chess_move.py b/src/apps/chess/business_logic/_do_chess_move.py index 211a2fd..4a80f66 100644 --- a/src/apps/chess/business_logic/_do_chess_move.py +++ b/src/apps/chess/business_logic/_do_chess_move.py @@ -1,14 +1,13 @@ -from functools import lru_cache from typing import TYPE_CHECKING, Literal, cast import chess -from apps.chess.chess_helpers import ( +from ..chess_helpers import ( chess_lib_piece_to_piece_type, file_and_rank_from_square, square_from_file_and_rank, ) -from apps.chess.types import ( +from ..types import ( ChessInvalidMoveException, ChessMoveResult, GameOverDescription, @@ -62,12 +61,27 @@ } -@lru_cache(maxsize=512) -def do_chess_move(*, fen: "FEN", from_: "Square", to: "Square") -> ChessMoveResult: +def do_chess_move( + *, + from_: "Square", + to: "Square", + fen: "FEN | None" = None, + chess_board: chess.Board | None = None, +) -> ChessMoveResult: + """ + Execute a move on the given board and return the result of that move. + The board can be passed as a FEN string *or* as a `chess.Board` object. + """ + if (not fen and not chess_board) or (fen and chess_board): + raise ValueError( + "You must provide either a FEN string or a `chess.Board` object" + ) + moves: list["MoveTuple"] = [] captured: "Square | None" = None - chess_board = chess.Board(fen) + if not chess_board: + chess_board = chess.Board(fen) chess_from = chess.parse_square(from_) chess_to = chess.parse_square(to) diff --git a/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py new file mode 100644 index 0000000..37a3149 --- /dev/null +++ b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py @@ -0,0 +1,78 @@ +from typing import TYPE_CHECKING, NamedTuple, cast + +from ..chess_helpers import ( + get_active_player_side_from_chess_board, + get_active_player_side_from_fen, +) + +if TYPE_CHECKING: + import chess + + from ..types import ( + FEN, + ChessInvalidStateException, + ChessMoveResult, + PieceRole, + PieceRoleBySquare, + PieceSymbol, + Square, + ) + + +class ChessMoveWithPieceRoleBySquareResult(NamedTuple): + move_result: "ChessMoveResult" + piece_role_by_square: "PieceRoleBySquare" + captured_piece: "PieceRole | None" + + +def do_chess_move_with_piece_role_by_square( + *, + from_: "Square", + to: "Square", + piece_role_by_square: "PieceRoleBySquare", + fen: "FEN | None" = None, + chess_board: "chess.Board | None" = None, +) -> ChessMoveWithPieceRoleBySquareResult: + from ._do_chess_move import do_chess_move + + if (not fen and not chess_board) or (fen and chess_board): + raise ValueError( + "You must provide either a FEN string or a `chess.Board` object" + ) + + try: + move_result = do_chess_move( + fen=fen, + chess_board=chess_board, + from_=from_, + to=to, + ) + except ValueError as err: + raise ChessInvalidStateException(f"Suspicious chess move: '{err}'") from err + + active_player_side = ( + get_active_player_side_from_fen(fen) + if fen + else get_active_player_side_from_chess_board(chess_board) # type: ignore[arg-type] + ) + piece_role_by_square = piece_role_by_square.copy() + if promotion := move_result["promotion"]: + # Let's promote that piece! + piece_promotion = cast( + "PieceSymbol", promotion.upper() if active_player_side == "w" else promotion + ) + piece_role_by_square[from_] += piece_promotion # type: ignore + + captured_piece: "PieceRole | None" = None + if captured := move_result["captured"]: + assert move_result["is_capture"] + captured_piece = piece_role_by_square[captured] + del piece_role_by_square[captured] # this square is now empty + + for move_from, move_to in move_result["moves"]: + piece_role_by_square[move_to] = piece_role_by_square[move_from] + del piece_role_by_square[move_from] # this square is now empty + + return ChessMoveWithPieceRoleBySquareResult( + move_result, piece_role_by_square, captured_piece + ) diff --git a/src/apps/chess/chess_helpers.py b/src/apps/chess/chess_helpers.py index bb34af8..b4e797a 100644 --- a/src/apps/chess/chess_helpers.py +++ b/src/apps/chess/chess_helpers.py @@ -1,4 +1,4 @@ -from functools import cache +from functools import cache, lru_cache from typing import TYPE_CHECKING, cast import chess @@ -29,7 +29,7 @@ @cache def chess_lib_square_to_square(chess_lib_square: int) -> "Square": - return cast("Square", chess.square_name(chess_lib_square)) + return cast("Square", chess.SQUARE_NAMES[chess_lib_square]) @cache @@ -145,6 +145,7 @@ def get_active_player_side_from_chess_board(board: chess.Board) -> "PlayerSide": return "w" if board.turn else "b" +@lru_cache def uci_move_squares(move: str) -> tuple["Square", "Square"]: return cast("Square", move[:2]), cast("Square", move[2:4]) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 32a5ffb..65901b8 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -29,10 +29,10 @@ from dominate.tags import dom_tag + from ..models import GameFactions from ..presenters import GamePresenter from ..types import ( BoardOrientation, - Factions, PieceRole, PieceType, PlayerSide, @@ -241,8 +241,15 @@ def chess_board(*, game_presenter: "GamePresenter", board_id: str) -> "dom_tag": def chess_pieces( *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str ) -> "dom_tag": + pieces_to_append: "list[tuple[Square, PieceRole]]" = sorted( + # We sort the pieces by their role, so that the pieces are always displayed + # in the same order, regardless of their position on the chess board. + game_presenter.piece_role_by_square.items(), + key=lambda item: item[1], + ) + pieces: "list[dom_tag]" = [] - for square, piece_role in game_presenter.piece_role_by_square.items(): + for square, piece_role in pieces_to_append: pieces.append( chess_piece( square=square, @@ -501,7 +508,7 @@ def chess_character_display( game_presenter: "GamePresenter | None" = None, square: "Square | None" = None, additional_classes: "Sequence[str]|None" = None, - factions: "Factions | None" = None, + factions: "GameFactions | None" = None, board_orientation: "BoardOrientation" = "1->8", ) -> "dom_tag": assert ( @@ -569,7 +576,7 @@ def chess_character_display( "top-2" if is_knight and is_from_original_left_hand_side else "top-1" ) - game_factions = cast("Factions", factions or game_presenter.factions) # type: ignore + game_factions = cast("GameFactions", factions or game_presenter.factions) # type: ignore classes = [ "relative", @@ -633,7 +640,7 @@ def chess_unit_display_with_ground_marker( *, piece_role: "PieceRole", game_presenter: "GamePresenter | None" = None, - factions: "Factions | None" = None, + factions: "GameFactions | None" = None, ) -> "dom_tag": assert ( game_presenter or factions diff --git a/src/apps/chess/components/chess_helpers.py b/src/apps/chess/components/chess_helpers.py index c23b039..3ce52e3 100644 --- a/src/apps/chess/components/chess_helpers.py +++ b/src/apps/chess/components/chess_helpers.py @@ -11,10 +11,10 @@ if TYPE_CHECKING: from collections.abc import Sequence + from apps.chess.models import GameFactions from apps.chess.types import ( BoardOrientation, Faction, - Factions, File, PieceName, PieceRole, @@ -163,35 +163,21 @@ def piece_should_face_left( ) -def piece_character_classes( - *, - board_orientation: "BoardOrientation", - piece_role: "PieceRole", - factions: "Factions", -) -> "Sequence[str]": - return _piece_character_classes_for_factions( - board_orientation=board_orientation, - piece_role=piece_role, - factions_tuple=tuple(factions.items()), - ) - - @cache -def _piece_character_classes_for_factions( +def piece_character_classes( *, board_orientation: "BoardOrientation", piece_role: "PieceRole", - factions_tuple: "tuple[tuple[PlayerSide, Faction], ...]", + factions: "GameFactions", ) -> "Sequence[str]": - # N.B. We use a tuple here for the factions, so they're hashable and can be used as cached key - piece_name = PIECE_TYPE_TO_NAME[type_from_piece_role(piece_role)] player_side = player_side_from_piece_role(piece_role) - factions_dict = dict(factions_tuple) - faction = factions_dict[player_side] + piece_name = PIECE_TYPE_TO_NAME[type_from_piece_role(piece_role)] + faction = factions.get_faction_for_side(player_side) classes = [_PIECE_UNITS_CLASSES[faction][piece_name]] - player_side = player_side_from_piece_role(piece_role) + if piece_should_face_left(board_orientation, player_side): classes.append("-scale-x-100") + return classes diff --git a/src/apps/chess/consts.py b/src/apps/chess/consts.py index 91708d3..bc6f0b1 100644 --- a/src/apps/chess/consts.py +++ b/src/apps/chess/consts.py @@ -40,7 +40,7 @@ RANKS: Final[tuple["Rank", ...]] = ("1", "2", "3", "4", "5", "6", "7", "8") -MOVES = frozenset(f"{sq1}{sq2}" for sq1 in SQUARES for sq2 in SQUARES if sq1 != sq2) +# MOVES = frozenset(f"{sq1}{sq2}" for sq1 in SQUARES for sq2 in SQUARES if sq1 != sq2) STARTING_PIECES: dict["PlayerSide", tuple["PieceSymbol"]] = { "w": (*("P" * 8), *("N" * 2), *("B" * 2), *("R" * 2), "Q", "K"), # type: ignore diff --git a/src/apps/chess/models.py b/src/apps/chess/models.py index b17f122..3ae7a94 100644 --- a/src/apps/chess/models.py +++ b/src/apps/chess/models.py @@ -1,9 +1,13 @@ import enum -from typing import Self +from typing import TYPE_CHECKING, NamedTuple, Self import msgspec from django.db import models +if TYPE_CHECKING: + from .types import Faction, GameTeamsDict, PlayerSide, TeamMemberRole + + # MsgSpec doesn't seem to be handling Django Choices correctly, so we have one # "Python enum" for the Struct and one `models.IntegerChoices` derived from it for # Django-related operations (such a forms) 😔 @@ -59,3 +63,51 @@ def to_cookie_content(self) -> str: @classmethod def from_cookie_content(cls, cookie_content: str) -> Self: return msgspec.json.decode(cookie_content.encode(), type=cls) + + +class GameFactions(NamedTuple): + w: "Faction" # the faction for the "w" player + b: "Faction" # the faction for the "b" player + + def get_faction_for_side(self, item: "PlayerSide") -> "Faction": + return getattr(self, item) + + +class TeamMember(NamedTuple): + role: "TeamMemberRole" + name: tuple[str, ...] + faction: "Faction | None" = None + + +class GameTeams(NamedTuple): + """ + We'll use this immutable class to store the team members for each player side. + """ + + w: tuple["TeamMember", ...] # the team members for the "w" player + b: tuple["TeamMember", ...] # the team members for the "b" player + + def get_team_for_side(self, item: "PlayerSide") -> "tuple[TeamMember]": + return getattr(self, item) + + def to_dict(self) -> "GameTeamsDict": + """ + Used to store that in the database + """ + return {"w": list(self.w), "b": list(self.b)} + + @classmethod + def from_dict(cls, data: "GameTeamsDict") -> "GameTeams": + """ + Used to re-hydrate the data from the database. + """ + return cls( + w=tuple( + TeamMember(**member) if isinstance(member, dict) else member # type: ignore[arg-type] + for member in data["w"] + ), + b=tuple( + TeamMember(**member) if isinstance(member, dict) else member # type: ignore[arg-type] + for member in data["b"] + ), + ) diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 252cd93..95bfc0c 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -22,19 +22,17 @@ if TYPE_CHECKING: from dominate.util import text + from .models import GameFactions, GameTeams, TeamMember from .types import ( FEN, BoardOrientation, - Factions, GamePhase, - GameTeams, PieceRole, PieceRoleBySquare, PieceSymbol, PieceType, PlayerSide, Square, - TeamMember, TeamMemberRole, ) @@ -179,7 +177,7 @@ def game_id(self) -> str: ... @property @abstractmethod - def factions(self) -> "Factions": ... + def factions(self) -> "GameFactions": ... @property @abstractmethod @@ -210,8 +208,8 @@ def team_members_by_role_by_side( result: "dict[PlayerSide, dict[TeamMemberRole, TeamMember]]" = {} for player_side in PLAYER_SIDES: result[player_side] = {} - for team_member in self._teams[player_side]: - member_role = team_member_role_from_piece_role(team_member["role"]) + for team_member in self._teams.get_team_for_side(player_side): + member_role = team_member_role_from_piece_role(team_member.role) result[player_side][member_role] = team_member return result diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index edf9e4b..c1ba07b 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -1,7 +1,12 @@ -from typing import Literal, Required, TypeAlias, TypedDict +from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict + +if TYPE_CHECKING: + from .models import TeamMember # https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation FEN: TypeAlias = str +# https://en.wikipedia.org/wiki/Portable_Game_Notation +PGN: TypeAlias = str # fmt: off PlayerSide = Literal[ @@ -72,6 +77,7 @@ ] # fmt: on +UCIMove: TypeAlias = str # e.g. "e2e4", "a7a8q"... MoveTuple = tuple[Square, Square] SquareColor = Literal["light", "dark"] @@ -113,8 +119,6 @@ "undeads", ] -Factions: TypeAlias = dict[PlayerSide, Faction] - class GameOverDescription(TypedDict): winner: "PlayerSide | None" @@ -131,14 +135,7 @@ class ChessMoveResult(TypedDict): game_over: GameOverDescription | None -class TeamMember(TypedDict, total=False): - role: Required["TeamMemberRole"] - # TODO: change this to just `lst[str]` when we finished migrating to a list-name - name: Required[list[str] | str] - faction: "Faction" - - -GameTeams: TypeAlias = dict["PlayerSide", list["TeamMember"]] +GameTeamsDict: TypeAlias = "dict[PlayerSide, list[TeamMember]]" class ChessLogicException(Exception): diff --git a/src/apps/daily_challenge/business_logic/__init__.py b/src/apps/daily_challenge/business_logic/__init__.py index 42a3a50..7c91bd6 100644 --- a/src/apps/daily_challenge/business_logic/__init__.py +++ b/src/apps/daily_challenge/business_logic/__init__.py @@ -1,4 +1,5 @@ # ruff: noqa: F401 + from ._compute_fields_before_bot_first_move import compute_fields_before_bot_first_move from ._get_current_daily_challenge import get_current_daily_challenge from ._get_speech_bubble import get_speech_bubble diff --git a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py index 7651ba8..b8070ca 100644 --- a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py +++ b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py @@ -13,7 +13,8 @@ if TYPE_CHECKING: import chess - from apps.chess.types import PlayerSide, Square, TeamMember + from apps.chess.models import TeamMember + from apps.chess.types import PlayerSide, Square from apps.daily_challenge.presenters import DailyChallengeGamePresenter # This code was originally part of the DailyChallengeGamePresenter class, @@ -91,11 +92,7 @@ def get_speech_bubble( game_presenter.challenge.my_side ][team_member_role] ) - if isinstance(name := captured_team_member["name"], str): - # TODO: remove that code when we finished migrating to a list-name - captured_team_member_display = name.split(" ")[0] - else: - captured_team_member_display = name[0] + captured_team_member_display = captured_team_member.name[0] reaction, reaction_time_out = random.choice(_UNIT_LOST_REACTIONS) return SpeechBubbleData( text=reaction.format(captured_team_member_display), diff --git a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py index c487974..7a4dac9 100644 --- a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py +++ b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py @@ -1,52 +1,35 @@ -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, NamedTuple -from apps.chess.business_logic import do_chess_move -from apps.chess.chess_helpers import get_active_player_side_from_fen -from apps.chess.types import ChessInvalidStateException +from apps.chess.business_logic import do_chess_move_with_piece_role_by_square from ..models import PlayerGameOverState if TYPE_CHECKING: - from apps.chess.types import PieceRole, PieceSymbol, Square + from apps.chess.types import PieceRole, Square from ..models import PlayerGameState +class MoveDailyChallengePieceResult(NamedTuple): + game_state: "PlayerGameState" + captured_piece: "PieceRole | None" + + def move_daily_challenge_piece( *, game_state: "PlayerGameState", from_: "Square", to: "Square", is_my_side: bool, -) -> tuple["PlayerGameState", "PieceRole | None"]: - fen = game_state.fen - active_player_side = get_active_player_side_from_fen(fen) - try: - move_result = do_chess_move( - fen=fen, +) -> MoveDailyChallengePieceResult: + move_result, piece_role_by_square, captured_piece = ( + do_chess_move_with_piece_role_by_square( + fen=game_state.fen, from_=from_, to=to, + piece_role_by_square=game_state.piece_role_by_square, ) - except ValueError as err: - raise ChessInvalidStateException(f"Suspicious chess move: '{err}'") from err - - piece_role_by_square = game_state.piece_role_by_square.copy() - if promotion := move_result["promotion"]: - # Let's promote that piece! - piece_promotion = cast( - "PieceSymbol", promotion.upper() if active_player_side == "w" else promotion - ) - piece_role_by_square[from_] += piece_promotion # type: ignore - - captured_piece: "PieceRole | None" = None - if captured := move_result["captured"]: - assert move_result["is_capture"] - captured_piece = piece_role_by_square[captured] - del piece_role_by_square[captured] # this square is now empty - - for move_from, move_to in move_result["moves"]: - piece_role_by_square[move_to] = piece_role_by_square[move_from] - del piece_role_by_square[move_from] # this square is now empty + ) if game_over := move_result["game_over"]: game_over_state = ( @@ -69,4 +52,4 @@ def move_daily_challenge_piece( new_game_state.turns_counter += 1 new_game_state.current_attempt_turns_counter += 1 - return new_game_state, captured_piece + return MoveDailyChallengePieceResult(new_game_state, captured_piece) diff --git a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py index 9dd6522..7d44bef 100644 --- a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py +++ b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py @@ -1,5 +1,5 @@ import random -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, TypeAlias, cast import chess @@ -10,16 +10,15 @@ player_side_other, ) from apps.chess.data.team_member_names import FIRST_NAMES, LAST_NAMES +from apps.chess.models import GameTeams, TeamMember if TYPE_CHECKING: from apps.chess.types import ( FEN, Faction, - GameTeams, PieceRoleBySquare, PieceType, PlayerSide, - TeamMember, TeamMemberRole, ) @@ -32,6 +31,8 @@ chess.KING: "k", } +TeamsDict: TypeAlias = "dict[PlayerSide, list[TeamMember]]" + def set_daily_challenge_teams_and_pieces_roles( *, @@ -41,7 +42,7 @@ def set_daily_challenge_teams_and_pieces_roles( bot_side: "PlayerSide" = "b", # TODO: allow partial customisation of team members? # custom_team_members: "GameTeams | None" = None, -) -> tuple["GameTeams", "PieceRoleBySquare"]: +) -> tuple[GameTeams, "PieceRoleBySquare"]: chess_board = chess.Board(fen) # fmt: off @@ -66,7 +67,7 @@ def set_daily_challenge_teams_and_pieces_roles( "b": default_faction_b, } - teams: "GameTeams" = {"w": [], "b": []} + teams: TeamsDict = {"w": [], "b": []} for chess_square, chess_piece in chess_board.piece_map().items(): piece_player_side = chess_lib_color_to_player_side(chess_piece.color) @@ -95,27 +96,33 @@ def set_daily_challenge_teams_and_pieces_roles( square = chess_lib_square_to_square(chess_square) piece_role_by_square[square] = piece_role - team_member: "TeamMember" = { - "role": team_member_role, - "name": "", # will be filled below by `_set_character_names_for_non_bot_side` - "faction": piece_faction[piece_player_side], - } + team_member = TeamMember( + role=team_member_role, + name=tuple(), # will be filled below by `_set_character_names_for_non_bot_side` + faction=piece_faction[piece_player_side], + ) teams[piece_player_side].append(team_member) team_members_counters[piece_player_side][piece_type][0] += 1 # Give a name to the player's team members - _set_character_names_for_non_bot_side(teams, bot_side=bot_side) + player_side = player_side_other(bot_side) + _set_character_names_for_team(teams, player_side) - return teams, piece_role_by_square + return ( + GameTeams(w=tuple(teams["w"]), b=tuple(teams["b"])), + piece_role_by_square, + ) -def _set_character_names_for_non_bot_side( - teams: "GameTeams", bot_side: "PlayerSide" -) -> None: - player_side: "PlayerSide" = player_side_other(bot_side) - player_team_members = teams[player_side] - first_names = random.sample(FIRST_NAMES, k=len(player_team_members)) - last_names = random.sample(LAST_NAMES, k=len(player_team_members)) - for team_member in player_team_members: - team_member["name"] = [first_names.pop(), last_names.pop()] +def _set_character_names_for_team(teams: TeamsDict, side: "PlayerSide") -> None: + anonymous_team_members = teams[side] + first_names = random.sample(FIRST_NAMES, k=len(anonymous_team_members)) + last_names = random.sample(LAST_NAMES, k=len(anonymous_team_members)) + + named_team_members: list[TeamMember] = [] + for team_member in anonymous_team_members: + named_team_members.append( + team_member._replace(name=(first_names.pop(), last_names.pop())) + ) + teams[side] = named_team_members diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index bddb975..e69828a 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -19,9 +19,8 @@ if TYPE_CHECKING: from dominate.tags import dom_tag + from apps.chess.models import GameFactions from apps.chess.types import ( - Faction, - Factions, PieceName, PieceRole, PieceType, @@ -29,7 +28,6 @@ TeamMemberRole, ) - _CHARACTER_TYPE_TIP: dict["PieceType", str] = { "p": "Characters with swords", "n": "Mounted characters", @@ -54,13 +52,12 @@ def help_content( *, challenge_solution_turns_count: int, - factions_tuple: "tuple[tuple[PlayerSide, Faction], ...]", + factions: "GameFactions", ) -> "dom_tag": # N.B. We use a tuple here for the factions, so they're hashable # and can be used as cached key spacing = "mb-3" - factions = dict(factions_tuple) return raw( div( @@ -146,7 +143,7 @@ def help_content( def chess_status_bar_tip( *, - factions: "Factions", + factions: "GameFactions", piece_type: "PieceType | None" = None, additional_classes: str = "", row_counter: int | None = None, @@ -178,7 +175,7 @@ def chess_status_bar_tip( def unit_display_container( - *, piece_role: "PieceRole", factions: "Factions", row_counter: int | None = None + *, piece_role: "PieceRole", factions: "GameFactions", row_counter: int | None = None ) -> "dom_tag": from apps.chess.components.chess_board import chess_unit_display_with_ground_marker diff --git a/src/apps/daily_challenge/components/misc_ui/help_modal.py b/src/apps/daily_challenge/components/misc_ui/help_modal.py index 7d062da..ef805d3 100644 --- a/src/apps/daily_challenge/components/misc_ui/help_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/help_modal.py @@ -25,7 +25,7 @@ def help_modal(*, game_presenter: "DailyChallengeGamePresenter") -> "dom_tag": body=div( help_content( challenge_solution_turns_count=game_presenter.challenge_solution_turns_count, - factions_tuple=tuple(game_presenter.factions.items()), + factions=game_presenter.factions, ), cls="p-6 space-y-6", ), diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/misc_ui/status_bar.py index d1236e6..4ff00b4 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/status_bar.py @@ -41,7 +41,7 @@ def status_bar( inner_content = div( help_content( challenge_solution_turns_count=game_presenter.challenge_solution_turns_count, - factions_tuple=tuple(game_presenter.factions.items()), + factions=game_presenter.factions, ), div( button( @@ -109,12 +109,7 @@ def _chess_status_bar_selected_piece( unit_display = unit_display_container( piece_role=piece_role, factions=game_presenter.factions ) - team_member_name = team_member.get("name", "") - name_display = ( - " ".join(team_member_name) - if isinstance(team_member_name, list) - else team_member_name - ) + name_display = " ".join(team_member.name) unit_about = div( div("> ", b(name_display, cls="text-yellow-400"), " <") if name_display else "", diff --git a/src/apps/daily_challenge/consts.py b/src/apps/daily_challenge/consts.py index f9bb877..5df996b 100644 --- a/src/apps/daily_challenge/consts.py +++ b/src/apps/daily_challenge/consts.py @@ -1,8 +1,12 @@ from typing import TYPE_CHECKING, Final +from apps.chess.models import GameFactions + if TYPE_CHECKING: - from apps.chess.types import Factions, PlayerSide + from apps.chess.types import PlayerSide PLAYER_SIDE: "Final[PlayerSide]" = "w" BOT_SIDE: "Final[PlayerSide]" = "b" -FACTIONS: "Final[Factions]" = {"w": "humans", "b": "undeads"} # hard-coded for now +FACTIONS: "Final[GameFactions]" = GameFactions( + w="humans", b="undeads" +) # hard-coded for now diff --git a/src/apps/daily_challenge/models.py b/src/apps/daily_challenge/models.py index 765552e..ce9e2c3 100644 --- a/src/apps/daily_challenge/models.py +++ b/src/apps/daily_challenge/models.py @@ -22,8 +22,9 @@ from .consts import BOT_SIDE, FACTIONS, PLAYER_SIDE if TYPE_CHECKING: - from apps.chess.types import Factions, GameTeams, Square + from apps.chess.types import GameTeamsDict, Square + from ..chess.models import GameFactions GameID: TypeAlias = str @@ -117,7 +118,7 @@ class DailyChallenge(models.Model): piece_role_by_square_before_bot_first_move: "PieceRoleBySquare | None" = ( models.JSONField(null=True, editable=False) ) - teams: "GameTeams|None" = models.JSONField(null=True, editable=False) + teams: "GameTeamsDict | None" = models.JSONField(null=True, editable=False) intro_turn_speech_text: str = models.CharField(max_length=100, blank=True) solution_turns_count: int = models.PositiveSmallIntegerField( null=True, editable=False @@ -135,7 +136,7 @@ def bot_side(self) -> PlayerSide: return BOT_SIDE @property - def factions(self) -> "Factions": + def factions(self) -> "GameFactions": return FACTIONS def clean(self) -> None: @@ -184,7 +185,7 @@ def _set_inferred_fields_for_published_daily_challenge( teams, piece_role_by_square = set_daily_challenge_teams_and_pieces_roles( fen=self.fen ) - self.teams = teams + self.teams = teams.to_dict() self.piece_role_by_square = piece_role_by_square # Set `*_before_bot_first_move` fields. Can raise validation errors. diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 76402ea..fe9d87e 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -5,6 +5,7 @@ from django.urls import reverse from apps.chess.chess_helpers import uci_move_squares +from apps.chess.models import GameTeams from apps.chess.presenters import GamePresenter, GamePresenterUrls from .business_logic import get_speech_bubble @@ -12,11 +13,10 @@ if TYPE_CHECKING: import chess - from apps.chess.models import UserPrefs + from apps.chess.models import GameFactions, UserPrefs from apps.chess.presenters import SpeechBubbleData from apps.chess.types import ( BoardOrientation, - Factions, GamePhase, PieceRole, PlayerSide, @@ -54,7 +54,7 @@ def __init__( super().__init__( fen=game_state.fen, piece_role_by_square=game_state.piece_role_by_square, - teams=challenge.teams, + teams=GameTeams.from_dict(challenge.teams), refresh_last_move=refresh_last_move, is_htmx_request=is_htmx_request, selected_piece_square=selected_piece_square, @@ -140,7 +140,7 @@ def game_id(self) -> str: return str(self._challenge.id) @cached_property - def factions(self) -> "Factions": + def factions(self) -> "GameFactions": return self._challenge.factions @cached_property diff --git a/src/apps/lichess_bridge/business_logic/__init__.py b/src/apps/lichess_bridge/business_logic/__init__.py new file mode 100644 index 0000000..021d49e --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/__init__.py @@ -0,0 +1,9 @@ +# ruff: noqa: F401 + +from ._create_teams_and_piece_role_by_square_for_starting_position import ( + create_teams_and_piece_role_by_square_for_starting_position, +) +from ._rebuild_game_from_starting_position import ( + RebuildGameFromStartingPositionResult, + rebuild_game_from_starting_position, +) diff --git a/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py new file mode 100644 index 0000000..cb09b8a --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py @@ -0,0 +1,72 @@ +import functools +from typing import TYPE_CHECKING, cast + +import chess + +from apps.chess.chess_helpers import ( + chess_lib_color_to_player_side, + chess_lib_square_to_square, + team_member_role_from_piece_role, +) +from apps.chess.models import GameTeams, TeamMember + +if TYPE_CHECKING: + from apps.chess.models import GameFactions + from apps.chess.types import ( + PieceRole, + PieceRoleBySquare, + PieceSymbol, + PlayerSide, + Square, + ) + +# Since we cache the result of this function, we want to return an immutable object. +# --> instead of returning a PieceRoleBySquare dict, we return a tuple of tuples - from +# which we can easily re-create a PieceRoleBySquare dict. +PieceRoleBySquareTuple = tuple[tuple["Square", "PieceRole"], ...] + + +@functools.cache +def create_teams_and_piece_role_by_square_for_starting_position( + factions: "GameFactions", +) -> "tuple[GameTeams, PieceRoleBySquareTuple]": + # fmt: off + piece_counters: dict["PieceSymbol", int | None] = { + "P": 0, "R": 0, "N": 0, "B": 0, "Q": None, "K": None, + "p": 0, "r": 0, "n": 0, "b": 0, "q": None, "k": None, + } + # fmt: on + + teams: "dict[PlayerSide, list[TeamMember]]" = {"w": [], "b": []} + piece_role_by_square: "PieceRoleBySquare" = {} + chess_board = chess.Board() + for chess_square in chess.SQUARES: + piece = chess_board.piece_at(chess_square) + if not piece: + continue + + player_side = chess_lib_color_to_player_side(piece.color) + symbol = cast("PieceSymbol", piece.symbol()) # e.g. "P", "p", "R", "r"... + if piece_counters[symbol]: + piece_counters[symbol] += 1 # type: ignore[operator] + piece_role = cast( + "PieceRole", f"{symbol}{piece_counters[symbol]}" + ) # e.g "P1", "r2".... + else: + piece_role = cast("PieceRole", symbol) # e.g. "Q", "k"... + + team_member_role = team_member_role_from_piece_role(piece_role) + team_member = TeamMember( + role=team_member_role, + name=("",), + faction=factions.get_faction_for_side(player_side), + ) + teams[player_side].append(team_member) + + square = chess_lib_square_to_square(chess_square) + piece_role_by_square[square] = piece_role + + return ( + GameTeams(w=tuple(teams["w"]), b=tuple(teams["b"])), + tuple(piece_role_by_square.items()), + ) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py new file mode 100644 index 0000000..fef8bd5 --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py @@ -0,0 +1,59 @@ +from typing import TYPE_CHECKING, NamedTuple + +import chess + +from ...chess.business_logic import do_chess_move_with_piece_role_by_square +from ...chess.chess_helpers import chess_lib_square_to_square + +if TYPE_CHECKING: + import chess.pgn + + from apps.chess.types import PieceRoleBySquare, UCIMove + + from ...chess.models import GameFactions, GameTeams + + +class RebuildGameFromStartingPositionResult(NamedTuple): + chess_board: chess.Board + teams: "GameTeams" + piece_role_by_square: "PieceRoleBySquare" + moves: list["UCIMove"] + + +def rebuild_game_from_starting_position( + *, pgn_game: "chess.pgn.Game", factions: "GameFactions" +) -> RebuildGameFromStartingPositionResult: + from ._create_teams_and_piece_role_by_square_for_starting_position import ( + create_teams_and_piece_role_by_square_for_starting_position, + ) + + # We start with a "starting position "chess board"... + teams, piece_role_by_square_tuple = ( + create_teams_and_piece_role_by_square_for_starting_position(factions) + ) + piece_role_by_square = dict(piece_role_by_square_tuple) + + # ...and then we apply the moves from the game data to it. + chess_board = chess.Board() + uci_moves: list[str] = [] + for move in pgn_game.mainline_moves(): + from_, to = ( + chess_lib_square_to_square(move.from_square), + chess_lib_square_to_square(move.to_square), + ) + move_result, piece_role_by_square, captured_piece = ( + do_chess_move_with_piece_role_by_square( + from_=from_, + to=to, + piece_role_by_square=piece_role_by_square, + chess_board=chess_board, + ) + ) + uci_moves.append(move.uci()) + + return RebuildGameFromStartingPositionResult( + chess_board=chess_board, + teams=teams, + piece_role_by_square=piece_role_by_square, + moves=uci_moves, + ) diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 262e1cf..2752907 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -7,12 +7,23 @@ import msgspec from django.db import models -from apps.chess.types import FEN # used by msgspec, so it has to be a "real" import +from apps.chess.models import GameFactions +from apps.chess.types import FEN + +from .business_logic import rebuild_game_from_starting_position if TYPE_CHECKING: import chess - from apps.chess.types import PlayerSide + from apps.chess.models import GameTeams + from apps.chess.types import ( + Faction, + PieceRoleBySquare, + PlayerSide, + UCIMove, + ) + + from .business_logic import RebuildGameFromStartingPositionResult LichessAccessToken: TypeAlias = str # e.g. "lio_6EeGimHMalSVH9qMcfUc2JJ3xdBPlqrL" @@ -189,10 +200,16 @@ class LichessGameExport(msgspec.Struct): division: dict # ??? +class LichessGameExportMetadataPlayerSides(NamedTuple): + me: "LichessPlayerSide" + them: "LichessPlayerSide" + + class LichessGameExportMetadataPlayer(NamedTuple): id: LichessPlayerId username: LichessGameFullId player_side: "PlayerSide" + faction: "Faction" class LichessGameExportMetadataPlayers(NamedTuple): @@ -221,11 +238,27 @@ class LichessGameExportWithMetadata: my_player_id: "LichessPlayerId" @functools.cached_property - def chess_board(self) -> "chess.Board": + def pgn_game(self) -> "chess.pgn.Game": pgn_game = chess.pgn.read_game(io.StringIO(self.game_export.pgn)) if not pgn_game: raise ValueError("Could not read PGN game") - return pgn_game.end().board() + return pgn_game + + @functools.cached_property + def chess_board(self) -> "chess.Board": + return self._rebuilt_game.chess_board + + @functools.cached_property + def moves(self) -> "list[UCIMove]": + return self._rebuilt_game.moves + + @functools.cached_property + def piece_role_by_square(self) -> "PieceRoleBySquare": + return self._rebuilt_game.piece_role_by_square + + @functools.cached_property + def teams(self) -> "GameTeams": + return self._rebuilt_game.teams @functools.cached_property def active_player_side(self) -> "LichessPlayerSide": @@ -233,12 +266,7 @@ def active_player_side(self) -> "LichessPlayerSide": @functools.cached_property def players_from_my_perspective(self) -> "LichessGameExportMetadataPlayers": - my_side: "LichessPlayerSide" = ( - "white" - if self.game_export.players.white.user.id == self.my_player_id - else "black" - ) - their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + my_side, their_side = self._players_sides my_player: "LichessGameUser" = getattr(self.game_export.players, my_side).user their_player: "LichessGameUser" = getattr( @@ -250,13 +278,54 @@ def players_from_my_perspective(self) -> "LichessGameExportMetadataPlayers": id=my_player.id, username=my_player.name, player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], + faction="humans", ), them=LichessGameExportMetadataPlayer( id=their_player.id, username=their_player.name, player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], + faction="undeads", ), active_player="me" if self.active_player_side == my_side else "them", ) return result + + @functools.cached_property + def game_factions(self) -> GameFactions: + my_side = self._players_sides[0] + # For now we hard-code the fact that "me" always plays the "humans" faction, + # and "them" always plays the "undeads" faction. + factions: "tuple[Faction, Faction]" = ( + "humans", + "undeads", + ) + if my_side == "white": + w_faction, b_faction = factions + else: + b_faction, w_faction = factions + + return GameFactions( + w=w_faction, + b=b_faction, + ) + + @functools.cached_property + def _players_sides(self) -> LichessGameExportMetadataPlayerSides: + my_side: "LichessPlayerSide" = ( + "white" + if self.game_export.players.white.user.id == self.my_player_id + else "black" + ) + their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + + return LichessGameExportMetadataPlayerSides( + me=my_side, + them=their_side, + ) + + @functools.cached_property + def _rebuilt_game(self) -> "RebuildGameFromStartingPositionResult": + return rebuild_game_from_starting_position( + pgn_game=self.pgn_game, factions=self.game_factions + ) diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 2de6288..2c68e72 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -2,32 +2,21 @@ from typing import TYPE_CHECKING, cast from urllib.parse import urlencode -import chess -import chess.pgn from django.urls import reverse from apps.chess.presenters import GamePresenter, GamePresenterUrls -from ..chess.chess_helpers import ( - chess_lib_color_to_player_side, - chess_lib_square_to_square, - team_member_role_from_piece_role, -) +from ..chess.models import GameFactions if TYPE_CHECKING: from apps.chess.presenters import SpeechBubbleData from apps.chess.types import ( FEN, BoardOrientation, - Factions, + Faction, GamePhase, - GameTeams, - PieceRole, - PieceRoleBySquare, - PieceSymbol, PlayerSide, Square, - TeamMember, ) from .models import LichessGameExportWithMetadata @@ -48,14 +37,10 @@ def __init__( self._chess_board = game_data.chess_board fen = cast("FEN", self._chess_board.fen()) - teams, piece_role_by_square = self._create_teams_and_piece_role_by_square( - self._chess_board, self.factions - ) - super().__init__( fen=fen, - piece_role_by_square=piece_role_by_square, - teams=teams, + piece_role_by_square=game_data.piece_role_by_square, + teams=game_data.teams, refresh_last_move=refresh_last_move, is_htmx_request=is_htmx_request, selected_piece_square=selected_piece_square, @@ -95,13 +80,14 @@ def game_id(self) -> str: return self._game_data.game_export.id @cached_property - def factions(self) -> "Factions": + def factions(self) -> GameFactions: players = self._game_data.players_from_my_perspective - - return { - players.me.player_side: "humans", - players.them.player_side: "undeads", - } + w_faction: "Faction" = "humans" if players.me.player_side == "w" else "undeads" + b_faction: "Faction" = "undeads" if w_faction == "humans" else "humans" + return GameFactions( + w=w_faction, + b=b_faction, + ) @property def is_intro_turn(self) -> bool: @@ -115,48 +101,6 @@ def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": def speech_bubble(self) -> "SpeechBubbleData | None": return None - @staticmethod - def _create_teams_and_piece_role_by_square( - chess_board: "chess.Board", factions: "Factions" - ) -> "tuple[GameTeams, PieceRoleBySquare]": - # fmt: off - piece_counters:dict["PieceSymbol", int | None] = { - "P": 0, "R": 0, "N": 0, "B": 0, "Q": None, "K": None, - "p": 0, "r": 0, "n": 0, "b": 0, "q": None, "k": None, - } - # fmt: on - - teams: "GameTeams" = {"w": [], "b": []} - piece_role_by_square: "PieceRoleBySquare" = {} - for chess_square in chess.SQUARES: - piece = chess_board.piece_at(chess_square) - if not piece: - continue - - player_side = chess_lib_color_to_player_side(piece.color) - symbol = cast("PieceSymbol", piece.symbol()) # e.g. "P", "p", "R", "r"... - - if piece_counters[symbol] is not None: - piece_counters[symbol] += 1 # type: ignore[operator] - piece_role = cast( - "PieceRole", f"{symbol}{piece_counters[symbol]}" - ) # e.g "P1", "r2".... - else: - piece_role = cast("PieceRole", symbol) # e.g. "Q", "k"... - - team_member_role = team_member_role_from_piece_role(piece_role) - team_member: "TeamMember" = { - "role": team_member_role, - "name": "", - "faction": factions[player_side], - } - teams[player_side].append(team_member) - - square = chess_lib_square_to_square(chess_square) - piece_role_by_square[square] = piece_role - - return teams, piece_role_by_square - class LichessCorrespondenceGamePresenterUrls(GamePresenterUrls): def htmx_game_no_selection_url(self, *, board_id: str) -> str: From a0097a065a0ac21826403e77f73dab3089d666b5 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 12:56:28 +0100 Subject: [PATCH 17/42] [uv] Don't use the "package" mode, since this is an app Let's use `pip install -e .` instead --- Makefile | 10 +++++++++- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index b66fd39..fdbeb62 100644 --- a/Makefile +++ b/Makefile @@ -31,11 +31,18 @@ download_assets: ${PYTHON_BINS}/python scripts/download_assets.py ${download_assets_opts} .PHONY: backend/install -backend/install: uv_sync_opts ?= --all-extras --no-build +backend/install: uv_sync_opts ?= --all-extras backend/install: bin/uv .venv ## Install the Python dependencies (via uv) and install pre-commit +# Install Python dependencies: ${UV} sync ${uv_sync_opts} +# Install the project in editable mode, so we don't have to add "src/" to the Python path: + ${UV} pip install -e . +# Install pre-commit hooks: ${PYTHON_BINS}/pre-commit install +# Create a shim for Black (actually using Ruff), so the IDE can use it: @${SUB_MAKE} .venv/bin/black +# Create the database if it doesn't exist: + @${SUB_MAKE} db.sqlite3 .PHONY: backend/watch backend/watch: env_vars ?= @@ -189,6 +196,7 @@ django/manage: env_vars ?= django/manage: dotenv_file ?= .env.local django/manage: cmd ?= --help django/manage: .venv .env.local ## Run a Django management command + @echo "Running Django management command: ${cmd}" @DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} ${env_vars} \ ${PYTHON_BINS}/dotenv -f '${dotenv_file}' run -- \ ${PYTHON} manage.py ${cmd} diff --git a/pyproject.toml b/pyproject.toml index 7395db3..5bdc9b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ Repository = "https://github.com/olivierphi/zakuchess" [tool.uv] -package = true # symlinks the project's "src/" root folder to the venv +package = false [tool.ruff] diff --git a/uv.lock b/uv.lock index bc0ef54..380d009 100644 --- a/uv.lock +++ b/uv.lock @@ -1773,7 +1773,7 @@ wheels = [ [[package]] name = "zakuchess" version = "0.1.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "authlib" }, { name = "chess" }, From 284fad1529af55d32535eba05fcffd05cd138409 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 13:21:47 +0100 Subject: [PATCH 18/42] [django] Time has come to use the DatabaseCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ...which comes with unexpected required updates, as we now have to manage that cache using the async API when our Views are async 😅 (which makes complete sense in retrospect, but I didn't see it coming :-) --- Makefile | 2 + scripts/start_server.sh | 6 +- src/apps/conftest.py | 5 ++ src/apps/lichess_bridge/lichess_api.py | 19 ++--- src/apps/lichess_bridge/tests/test_views.py | 79 ++++++++++++++------- src/project/settings/_base.py | 4 +- 6 files changed, 72 insertions(+), 43 deletions(-) diff --git a/Makefile b/Makefile index fdbeb62..93d864d 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,8 @@ backend/install: bin/uv .venv ## Install the Python dependencies (via uv) and in @${SUB_MAKE} .venv/bin/black # Create the database if it doesn't exist: @${SUB_MAKE} db.sqlite3 +# Make sure the SQLite database is up-to-date: + @${SUB_MAKE} django/manage cmd='createcachetable' .PHONY: backend/watch backend/watch: env_vars ?= diff --git a/scripts/start_server.sh b/scripts/start_server.sh index 2ab7cb4..f713209 100644 --- a/scripts/start_server.sh +++ b/scripts/start_server.sh @@ -10,7 +10,11 @@ set -o errexit # initialised some environment variables in the Dockerfile, # such as DJANGO_SETTINGS_MODULE and GUNICORN_CMD_ARGS. -# TODO: remove this once we have a proper deployment pipeline +# TODO: remove this once we have a proper deployment pipeline? + +echo "Make sure the cache table is operational." +.venv/bin/python manage.py createcachetable + echo "Running Django migrations." .venv/bin/python manage.py migrate --noinput diff --git a/src/apps/conftest.py b/src/apps/conftest.py index 790312d..e161ddd 100644 --- a/src/apps/conftest.py +++ b/src/apps/conftest.py @@ -5,3 +5,8 @@ @pytest.fixture def cleared_django_default_cache() -> None: cache.clear() + + +@pytest.fixture +async def acleared_django_default_cache() -> None: + await cache.aclear() diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 01fdfee..47b5bf7 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -46,16 +46,16 @@ async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInfo """ This is cached for a short amount of time. """ - # Let's not expose any access tokens in our cache keys, - # and use a quick hash of them instead. + # Let's not expose any access tokens in our cache keys, and instead use a quick hash + # of the "Authorization" header (which contains the token). # "An Adler-32 checksum is almost as reliable as a CRC32 but can be computed much more quickly." # --> should be enough for our case :-) - lichess_access_token_hash = adler32(api_client.lichess_access_token.encode()) # type: ignore[attr-defined] + lichess_access_token_hash = adler32(api_client.headers["Authorization"].encode()) cache_key = _GET_MY_ACCOUNT_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] lichess_access_token_hash=lichess_access_token_hash ) - if cached_data := cache.get(cache_key): + if cached_data := await cache.aget(cache_key): _logger.info("Using cached data for 'get_my_account'.") response_content = cached_data else: @@ -66,7 +66,7 @@ async def get_my_account(*, api_client: httpx.AsyncClient) -> LichessAccountInfo response.raise_for_status() response_content = response.content - cache.set(cache_key, response_content, _GET_MY_ACCOUNT_CACHE["DURATION"]) + await cache.aset(cache_key, response_content, _GET_MY_ACCOUNT_CACHE["DURATION"]) return msgspec.json.decode(response_content, type=LichessAccountInformation) @@ -100,7 +100,7 @@ async def get_game_by_id( cache_key = _GET_GAME_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] game_id=game_id, ) - if cached_data := cache.get(cache_key): + if cached_data := await cache.aget(cache_key): _logger.info("Using cached data for 'get_game_by_id'.") response_content = cached_data else: @@ -113,7 +113,7 @@ async def get_game_by_id( response.raise_for_status() response_content = response.content - cache.set(cache_key, response_content, _GET_GAME_BY_ID_CACHE["DURATION"]) + await cache.aset(cache_key, response_content, _GET_GAME_BY_ID_CACHE["DURATION"]) return msgspec.json.decode(response_content, type=LichessGameExport) @@ -195,9 +195,4 @@ def _create_lichess_api_client(access_token: "LichessAccessToken") -> httpx.Asyn }, ) - # We store the access token in the client object, so we can access it later - # Not super clean, but... this is a side project, and where's the joy if I cannot - # allow myself some dirty shortcuts in such a context? 😄 - client.lichess_access_token = access_token # type: ignore[attr-defined] - return client diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 93551b1..7ffa49a 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -3,10 +3,29 @@ from typing import TYPE_CHECKING, Any from unittest import mock +import pytest + if TYPE_CHECKING: from django.test import AsyncClient as DjangoAsyncClient, Client as DjangoClient +class HttpClientMockBase: + def __init__(self, access_token): + self.lichess_access_token = access_token + + @property + def headers(self): + return {"Authorization": f"Bearer {self.lichess_access_token}"} + + +class HttpClientResponseMockBase: + def __init__(self, path): + self.path = path + + def raise_for_status(self): + pass + + def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): """Just a quick smoke test for now""" @@ -18,20 +37,18 @@ def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): assert "Log out from Lichess" not in response_html +@pytest.mark.django_db # just because we use the DatabaseCache async def test_lichess_homepage_with_access_token_smoke_test( async_client: "DjangoAsyncClient", - cleared_django_default_cache, + acleared_django_default_cache, ): """Just a quick smoke test for now""" access_token = "lio_123456789" async_client.cookies["lichess.access_token"] = access_token - class HttpClientMock: - class HttpClientResponseMock: - def __init__(self, path): - self.path = path - + class HttpClientMock(HttpClientMockBase): + class HttpClientResponseMock(HttpClientResponseMockBase): @property def content(self) -> str: # The client's response's `content` is a property @@ -49,12 +66,6 @@ def content(self) -> str: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) - def raise_for_status(self): - pass - - def __init__(self): - self.lichess_access_token = access_token - async def get(self, path, **kwargs): # The client's `get` method is async assert path.startswith("/api/") @@ -64,7 +75,7 @@ async def get(self, path, **kwargs): "apps.lichess_bridge.lichess_api._create_lichess_api_client", ) as create_lichess_api_client_mock: create_lichess_api_client_mock.return_value.__aenter__.return_value = ( - HttpClientMock() + HttpClientMock(access_token) ) response = await async_client.get("/lichess/") @@ -87,6 +98,7 @@ async def test_lichess_create_game_without_access_token_should_redirect( assert response.status_code == HTTPStatus.FOUND +@pytest.mark.django_db # just because we use the DatabaseCache async def test_lichess_create_game_with_access_token_smoke_test( async_client: "DjangoAsyncClient", ): @@ -132,26 +144,45 @@ async def test_lichess_correspondence_game_without_access_token_should_redirect( }, "opening": {"eco": "B01", "name": "Scandinavian Defense", "ply": 2}, "moves": "e4 d5", - "pgn": '[Event "Casual correspondence game"]\n[Site "https://lichess.org/tFXGsEcq"]\n[Date "2024.09.06"]\n[White "ChessChampion"]\n[Black "ChessMaster74960"]\n[Result "*"]\n[UTCDate "2024.09.06"]\n[UTCTime "15:37:24"]\n[WhiteElo "1500"]\n[BlackElo "2078"]\n[Variant "Standard"]\n[TimeControl "-"]\n[ECO "B01"]\n[Opening "Scandinavian Defense"]\n[Termination "Unterminated"]\n\n1. e4 d5 *\n\n\n', + "pgn": "\n".join( + # https://en.wikipedia.org/wiki/Portable_Game_Notation + ( + '[Event "Casual correspondence game"]', + '[Site "https://lichess.org/tFXGsEcq"]', + '[Date "2024.09.06"]', + '[White "ChessChampion"]', + '[Black "ChessMaster74960"]', + '[Result "*"]', + '[UTCDate "2024.09.06"]', + '[UTCTime "15:37:24"]', + '[WhiteElo "1500"]', + '[BlackElo "2078"]', + '[Variant "Standard"]', + '[TimeControl "-"]', + '[ECO "B01"]', + '[Opening "Scandinavian Defense"]', + '[Termination "Unterminated"]', + "\n1. e4 d5 *", + "\n\n", + ) + ), "daysPerTurn": 3, "division": {}, } +@pytest.mark.django_db # just because we use the DatabaseCache async def test_lichess_correspondence_game_with_access_token_smoke_test( async_client: "DjangoAsyncClient", - cleared_django_default_cache, + acleared_django_default_cache, ): """Just a quick smoke test for now""" access_token = "lio_123456789" async_client.cookies["lichess.access_token"] = access_token - class HttpClientMock: - class HttpClientResponseMock: - def __init__(self, path): - self.path = path - + class HttpClientMock(HttpClientMockBase): + class HttpClientResponseMock(HttpClientResponseMockBase): @property def content(self) -> str: # The client's response's `content` is a property @@ -169,12 +200,6 @@ def content(self) -> str: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) - def raise_for_status(self): - pass - - def __init__(self): - self.lichess_access_token = access_token - async def get(self, path, **kwargs): # The client's `get` method is async assert path.startswith(("/api/", "/game/export/")) @@ -183,7 +208,7 @@ async def get(self, path, **kwargs): with mock.patch( "apps.lichess_bridge.lichess_api._create_lichess_api_client", ) as create_lichess_api_client_mock: - client_mock = HttpClientMock() + client_mock = HttpClientMock(access_token) create_lichess_api_client_mock.return_value.__aenter__.return_value = ( client_mock ) diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index 15b6ede..298f5a9 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -107,9 +107,7 @@ CACHES = { "default": { - # Let's kiss things simple for now, and let each Django worker - # manage their own in-memory cache. - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "BACKEND": "django.core.cache.backends.db.DatabaseCache", } } From 3fa710102fe0804e93c1c3b150e5fcc5e9635b22 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 13:43:57 +0100 Subject: [PATCH 19/42] [chore] Update `uv` --- .github/workflows/test-suite.yml | 23 +++++++++++++---------- Dockerfile | 2 +- Makefile | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 924a10d..acbaa0a 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -20,26 +20,29 @@ jobs: steps: - uses: "actions/checkout@v4" - - name: Set up uv - # Install a specific uv version using the installer - run: "curl -LsSf https://astral.sh/uv/${UV_VERSION}/install.sh | sh" - env: - UV_VERSION: "0.4.4" + - name: Install uv + uses: astral-sh/setup-uv@v2 + with: + version: "0.4.9" + enable-cache: true + cache-dependency-glob: "uv.lock" - name: Set up Python run: uv python install - name: "Install dependencies via uv" run: uv sync --all-extras + - name: "Install the project in 'editable' mode" + run: uv pip install -e . - name: "Run linting checks: Ruff checker" - run: uv run ruff format --check --quiet src/ + run: uv run --no-sync ruff format --check --quiet src/ - name: "Run linting checks: Ruff linter" - run: uv run ruff check --quiet src/ + run: uv run --no-sync ruff check --quiet src/ - name: "Run linting checks: Mypy" - run: uv run mypy src/ + run: uv run --no-sync mypy src/ - name: "Check that Django DB migrations are up to date" - run: uv run python manage.py makemigrations | grep "No changes detected" + run: uv run --no-sync python manage.py makemigrations | grep "No changes detected" - name: "Run tests" # TODO: progressively increase minimum coverage to something closer to 80% - run: uv run pytest --cov=src --cov-report xml:coverage.xml + run: uv run --no-sync pytest --cov=src --cov-report xml:coverage.xml # --cov-fail-under=60 --> we'll actually do that with the "Report coverage" step - name: "Report coverage" # @link https://github.com/orgoro/coverage diff --git a/Dockerfile b/Dockerfile index 549b5ca..526afcc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,7 +74,7 @@ EOT # Install uv. # https://docs.astral.sh/uv/guides/integration/docker/ -COPY --from=ghcr.io/astral-sh/uv:0.4.4 /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv:0.4.9 /uv /usr/local/bin/uv RUN mkdir -p /app WORKDIR /app diff --git a/Makefile b/Makefile index 93d864d..55c0f80 100644 --- a/Makefile +++ b/Makefile @@ -170,7 +170,7 @@ frontend/img/copy_assets: # Here starts the "misc util targets" stuff -bin/uv: uv_version ?= 0.4.4 +bin/uv: uv_version ?= 0.4.9 bin/uv: # Install `uv` and `uvx` locally in the "bin/" folder curl -LsSf "https://astral.sh/uv/${uv_version}/install.sh" | \ CARGO_DIST_FORCE_INSTALL_DIR="$$(pwd)" INSTALLER_NO_MODIFY_PATH=1 sh From 6500d285a103397a9db1ced3c0980ad8dc501bd0 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 15:35:58 +0100 Subject: [PATCH 20/42] [fonts] Use `django-google-fonts` to mirror Google fonts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Better than using our quick-n-dirty manual solution 😅 --- Dockerfile | 1 - pyproject.toml | 1 + scripts/download_assets.py | 4 --- src/apps/webui/components/layout.py | 12 +++++---- src/apps/webui/static/.gitignore | 1 + src/project/settings/_base.py | 7 +++++ tailwind.config.js | 4 ++- uv.lock | 40 +++++++++++++++++++++++++++-- 8 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 src/apps/webui/static/.gitignore diff --git a/Dockerfile b/Dockerfile index 526afcc..5354496 100644 --- a/Dockerfile +++ b/Dockerfile @@ -159,7 +159,6 @@ COPY --chown=1001:1001 --from=frontend_build /app/src/apps/chess/static src/apps COPY --chown=1001:1001 --from=backend_build /app/.venv .venv -COPY --chown=1001:1001 --from=assets_download /app/src/apps/webui/static/webui/fonts/OpenSans.woff2 src/apps/webui/static/webui/fonts/OpenSans.woff2 COPY --chown=1001:1001 --from=assets_download /app/src/apps/chess/static/chess/js/bot src/apps/chess/static/chess/js/bot COPY --chown=1001:1001 --from=assets_download /app/src/apps/chess/static/chess/units src/apps/chess/static/chess/units COPY --chown=1001:1001 --from=assets_download /app/src/apps/chess/static/chess/symbols src/apps/chess/static/chess/symbols diff --git a/pyproject.toml b/pyproject.toml index 5bdc9b8..3e6b9b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies= [ "zakuchess", "authlib==1.*", "httpx==0.27.*", + "django-google-fonts==0.0.3", ] diff --git a/scripts/download_assets.py b/scripts/download_assets.py index 777c646..359e168 100755 --- a/scripts/download_assets.py +++ b/scripts/download_assets.py @@ -21,7 +21,6 @@ CHESS_STATIC = BASE_DIR / "src" / "apps" / "chess" / "static" / "chess" ASSETS_PATTERNS: dict[str, str] = { - "GOOGLE_FONTS": "https://fonts.gstatic.com/s/{font_name}/{v}/{file_id}.woff2", "STOCKFISH_CDN": "https://cdnjs.cloudflare.com/ajax/libs/stockfish.js/10.0.2/{file}", "LOZZA_GITHUB": "https://raw.githubusercontent.com/op12no2/lozza/{rev}/lozza.js", "WESNOTH_UNITS_GITHUB": "https://raw.githubusercontent.com/wesnoth/wesnoth/master/data/core/images/units/{path}", @@ -43,8 +42,6 @@ # fmt: off ASSETS_MAP: dict[URL, Path] = { - # Fonts: - ASSETS_PATTERNS["GOOGLE_FONTS"].format(font_name="opensans", file_id="mem8YaGs126MiZpBA-UFVZ0b", v="v35"): WEBUI_STATIC / "fonts" / "OpenSans.woff2", # Stockfish: ASSETS_PATTERNS["STOCKFISH_CDN"].format(file="stockfish.min.js"): CHESS_STATIC / "js" / "bot" / "stockfish.js", ASSETS_PATTERNS["STOCKFISH_CDN"].format(file="stockfish.wasm"): CHESS_STATIC / "js" / "bot" / "stockfish.wasm", @@ -90,7 +87,6 @@ async def download_assets(*, even_if_exists: bool) -> None: limits = httpx.Limits( max_connections=DOWNLOADS_CONCURRENCY, - max_keepalive_connections=DOWNLOADS_CONCURRENCY, ) async with httpx.AsyncClient( limits=limits, headers={"User-Agent": _USER_AGENT} diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index 6b33efe..673e69c 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -33,12 +33,7 @@ from dominate.util import text # We'll do something cleaner later -# TODO: subset the OpenSans font, once we have extracted text in i18n files. _FONTS_CSS = """ -@font-face { - font-family: 'OpenSans'; - src: url('/static/webui/fonts/OpenSans.woff2') format('woff2'); -} @font-face { font-family: 'PixelFont'; src: url('/static/webui/fonts/fibberish.ttf') format('truetype'); @@ -124,7 +119,14 @@ def head(*children: "dom_tag", title: str) -> "dom_tag": sizes="32x32", href=static("webui/img/favicon-32x32.png"), ), + # Fonts: style(_FONTS_CSS), + link( + # automatically created by `django-google-fonts` + rel="stylesheet", + href=static("fonts/opensans.css"), + ), + # CSS & JS link(rel="stylesheet", href=static("webui/css/zakuchess.css")), script(src=static("webui/js/main.js")), script(src=static("chess/js/chess-main.js")), diff --git a/src/apps/webui/static/.gitignore b/src/apps/webui/static/.gitignore new file mode 100644 index 0000000..8119d0a --- /dev/null +++ b/src/apps/webui/static/.gitignore @@ -0,0 +1 @@ +/fonts/ diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index 298f5a9..7e7e5f0 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -43,6 +43,7 @@ "django_htmx", "axes", # https://github.com/jazzband/django-axes "import_export", # https://django-import-export.readthedocs.io/ + "django_google_fonts", # https://github.com/andymckay/django-google-fonts ] + [ "apps.authentication", @@ -190,6 +191,12 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# Google fonts to mirror locally: +# https://github.com/andymckay/django-google-fonts +GOOGLE_FONTS = ( + "Open Sans", # https://fonts.google.com/specimen/Open+Sans +) +GOOGLE_FONTS_DIR = BASE_DIR / "src" / "apps" / "webui" / "static" # Our custom settings: ZAKUCHESS_VERSION = env.get("ZAKUCHESS_VERSION", "dev") diff --git a/tailwind.config.js b/tailwind.config.js index 66b8a71..ebeac28 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -17,13 +17,15 @@ const PIECE_SYMBOL_B = `#3730a3${PIECE_SYMBOL_BORDER_OPACITY}` // indigo-800 const PIECES_DROP_SHADOW_OFFSET = 1 // px const SPEECH_BUBBLE_DROP_SHADOW_COLOR = "#fbbf24" // amber-400 +// https://github.com/tailwindlabs/tailwindcss/blob/main/stubs/config.full.js + /** @type {import('tailwindcss').Config} */ module.exports = { content: ["./src/apps/*/components/**/*.py"], safelist: chessRelatedClassesSafeList(), theme: { fontFamily: { - sans: ["OpenSans", "sans-serif"], + sans: ["Open Sans", "sans-serif"], pixel: ["PixelFont", "monospace"], mono: ["monospace"], }, diff --git a/uv.lock b/uv.lock index 380d009..ed291eb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,7 @@ version = 1 requires-python = ">=3.11" resolution-markers = [ - "python_full_version == '3.11'", + "python_full_version <= '3.11'", "python_full_version > '3.11'", ] @@ -311,7 +311,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version == '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -451,6 +451,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/7e/ba12b9660642663f5273141018d2bec0a1cae1711f4f6d1093920e157946/django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401", size = 229868 }, ] +[[package]] +name = "django-google-fonts" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/e9/a0d5a8988e0450444f69f99c94a896da5c4a0f8405ab8ab863cc377ece44/django_google_fonts-0.0.3.tar.gz", hash = "sha256:0439c4d89919970b141258bd3be0a085cc538f4e9c53e54f40eb2953ec2e5d30", size = 6878 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/35/7096ffbfadbd9acf332d99de078a56a48d7632f5b8c56714aa29f3333b36/django_google_fonts-0.0.3-py3-none-any.whl", hash = "sha256:f8f0b943107932d5fbb66da26f2088e0c6d89cde8b66ed66c3f58a6f40d54994", size = 7944 }, +] + [[package]] name = "django-htmx" version = "1.13.0" @@ -1492,6 +1505,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/c8/26367d0b8dfaf7445576fe0051bff61b8f5be752e7bf3e8807ed7fa3a343/time_machine-2.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:008bd668d933b1a029c81805bcdc0132390c2545b103cf8e6709e3adbc37989d", size = 18337 }, ] +[[package]] +name = "tinycss2" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/6f/38d2335a2b70b9982d112bb177e3dbe169746423e33f718bf5e9c7b3ddd3/tinycss2-1.3.0.tar.gz", hash = "sha256:152f9acabd296a8375fbca5b84c961ff95971fcfc32e79550c8df8e29118c54d", size = 67360 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4d/0db5b8a613d2a59bbc29bc5bb44a2f8070eb9ceab11c50d477502a8a0092/tinycss2-1.3.0-py3-none-any.whl", hash = "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7", size = 22532 }, +] + [[package]] name = "tomli" version = "2.0.1" @@ -1678,6 +1703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + [[package]] name = "websockets" version = "13.0.1" @@ -1781,6 +1815,7 @@ dependencies = [ { name = "django" }, { name = "django-alive" }, { name = "django-axes", extra = ["ipware"] }, + { name = "django-google-fonts" }, { name = "django-htmx" }, { name = "django-import-export" }, { name = "dominate" }, @@ -1828,6 +1863,7 @@ requires-dist = [ { name = "django-alive", specifier = "==1.*" }, { name = "django-axes", extras = ["ipware"], specifier = "==6.*" }, { name = "django-extensions", marker = "extra == 'dev'", specifier = "==3.*" }, + { name = "django-google-fonts", specifier = "==0.0.3" }, { name = "django-htmx", specifier = "==1.*" }, { name = "django-import-export", specifier = "==4.*" }, { name = "dominate", specifier = "==2.*" }, From 15764e62965f14f18547281055224929ac29116e Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 15:43:35 +0100 Subject: [PATCH 21/42] [lichess] Fix bug in `create_teams_and_piece_role_by_square_for_starting_position` --- ...eate_teams_and_piece_role_by_square_for_starting_position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py index cb09b8a..51ac7f9 100644 --- a/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py +++ b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py @@ -47,7 +47,7 @@ def create_teams_and_piece_role_by_square_for_starting_position( player_side = chess_lib_color_to_player_side(piece.color) symbol = cast("PieceSymbol", piece.symbol()) # e.g. "P", "p", "R", "r"... - if piece_counters[symbol]: + if piece_counters[symbol] is not None: piece_counters[symbol] += 1 # type: ignore[operator] piece_role = cast( "PieceRole", f"{symbol}{piece_counters[symbol]}" From ee6981f1d591ed6b8773054914211497993d3b87 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 15:44:07 +0100 Subject: [PATCH 22/42] [lichess] Add some perf monitoring in `rebuild_game_from_starting_position` --- .../_rebuild_game_from_starting_position.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py index fef8bd5..1102a4c 100644 --- a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py @@ -1,3 +1,5 @@ +import logging +import time from typing import TYPE_CHECKING, NamedTuple import chess @@ -12,6 +14,8 @@ from ...chess.models import GameFactions, GameTeams +_logger = logging.getLogger(__name__) + class RebuildGameFromStartingPositionResult(NamedTuple): chess_board: chess.Board @@ -27,13 +31,17 @@ def rebuild_game_from_starting_position( create_teams_and_piece_role_by_square_for_starting_position, ) + start_time = time.monotonic() + # We start with a "starting position "chess board"... teams, piece_role_by_square_tuple = ( create_teams_and_piece_role_by_square_for_starting_position(factions) ) piece_role_by_square = dict(piece_role_by_square_tuple) - # ...and then we apply the moves from the game data to it. + # ...and then we apply the moves from the game data to it, one by one: + # (while keeping track of the piece roles on the board, so if "p1" moves, + # we can "follow" that pawn) chess_board = chess.Board() uci_moves: list[str] = [] for move in pgn_game.mainline_moves(): @@ -51,6 +59,12 @@ def rebuild_game_from_starting_position( ) uci_moves.append(move.uci()) + _logger.info( + "`rebuild_game_from_starting_position` took %d ms. for %d moves", + (time.monotonic() - start_time) * 1000, + len(uci_moves), + ) + return RebuildGameFromStartingPositionResult( chess_board=chess_board, teams=teams, From 11c732ebf12573f7663d71f7738ce9c8066fdca8 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Wed, 11 Sep 2024 16:12:52 +0100 Subject: [PATCH 23/42] [lichess] Minor bugfixes related to pieces selection And while we're here... Let's change the "active/opponent" semantic of the CSS classes we add to selected pieces and targets to "playable/non-playable", which is more accurate and generic. --- src/apps/chess/components/chess_board.py | 26 ++++++++++++------- src/apps/chess/presenters.py | 4 +++ .../components/pages/daily_chess_pages.py | 1 + src/apps/daily_challenge/presenters.py | 4 +++ src/apps/lichess_bridge/presenters.py | 26 +++++++++++++++++-- src/apps/lichess_bridge/urls.py | 10 +++---- src/apps/lichess_bridge/views.py | 26 +++++++++++++++++-- tailwind.config.js | 17 +++++------- 8 files changed, 85 insertions(+), 29 deletions(-) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 65901b8..92cb6f5 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -450,12 +450,12 @@ def chess_available_target( can_move = ( not game_presenter.is_game_over and game_presenter.is_my_turn - and game_presenter.active_player_side == piece_player_side + and game_presenter.my_side == piece_player_side ) bg_class = ( - "bg-active-chess-available-target-marker" + "bg-playable-chess-available-target-marker" if can_move - else "bg-opponent-chess-available-target-marker" + else "bg-non-playable-chess-available-target-marker" ) hover_class = "hover:w-1/3 hover:h-1/3" if can_move else "" target_marker = div( @@ -517,8 +517,14 @@ def chess_character_display( # Some data we'll need: piece_player_side = player_side_from_piece_role(piece_role) - is_active_player_piece = ( - game_presenter.active_player == piece_player_side if game_presenter else False + is_my_turn = game_presenter.is_my_turn if game_presenter else False + is_playable = is_my_turn and ( + ( + piece_player_side == game_presenter.my_side + and not game_presenter.is_game_over + ) + if game_presenter + else False ) is_potential_capture: bool = False is_highlighted: bool = False @@ -552,7 +558,7 @@ def chess_character_display( # Right, let's do this shall we? if ( is_king - and is_active_player_piece + and is_my_turn and game_presenter and game_presenter.solution_index is None and game_presenter.is_check @@ -560,7 +566,7 @@ def chess_character_display( is_potential_capture = True # let's highlight our king if it's in check elif ( is_king - and is_active_player_piece + and is_my_turn and game_presenter and game_presenter.solution_index is not None and game_presenter.is_check @@ -595,9 +601,9 @@ def chess_character_display( # Conditional classes: ( ( - "drop-shadow-active-selected-piece" - if is_active_player_piece - else "drop-shadow-opponent-selected-piece" + "drop-shadow-playable-selected-piece" + if is_playable + else "drop-shadow-non-playable-selected-piece" ) if is_highlighted else ( diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 95bfc0c..4f8fed0 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -118,6 +118,10 @@ def urls(self) -> "GamePresenterUrls": ... @abstractmethod def is_my_turn(self) -> bool: ... + @property + @abstractmethod + def my_side(self) -> "PlayerSide | None": ... + @property @abstractmethod def game_phase(self) -> "GamePhase": ... diff --git a/src/apps/daily_challenge/components/pages/daily_chess_pages.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py index 1714f30..c12ede7 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess_pages.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -197,6 +197,7 @@ def _open_help_modal() -> "dom_tag": def _open_modal(modal_id: "Literal['stats', 'help']", delay: int) -> "dom_tag": + # TODO: use a web component for this return div( script( raw(_MODAL_TEMPLATE.substitute(MODAL_ID=modal_id, DELAY=delay)), diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index fe9d87e..22474bc 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -86,6 +86,10 @@ def urls(self) -> "DailyChallengeGamePresenterUrls": def is_my_turn(self) -> bool: return not self.is_bot_turn + @cached_property + def my_side(self) -> "PlayerSide | None": + return self._challenge.my_side + @cached_property def challenge_current_attempt_turns_counter(self) -> int: return self.game_state.current_attempt_turns_counter diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 2c68e72..f1e3813 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -63,9 +63,20 @@ def urls(self) -> "GamePresenterUrls": def is_my_turn(self) -> bool: return self._game_data.players_from_my_perspective.active_player == "me" + @cached_property + def my_side(self) -> "PlayerSide | None": + return self._game_data.players_from_my_perspective.me.player_side + @cached_property def game_phase(self) -> "GamePhase": - return "waiting_for_player_selection" # TODO + # TODO: manage "game over" situations + if self.is_my_turn: + if self.selected_piece is None: + return "waiting_for_player_selection" + if self.selected_piece.target_to_confirm is None: + return "waiting_for_player_target_choice" + return "waiting_for_player_target_choice_confirmation" + return "waiting_for_opponent_turn" @cached_property def is_bot_turn(self) -> bool: @@ -104,7 +115,18 @@ def speech_bubble(self) -> "SpeechBubbleData | None": class LichessCorrespondenceGamePresenterUrls(GamePresenterUrls): def htmx_game_no_selection_url(self, *, board_id: str) -> str: - return "#" # TODO + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_no_selection", + kwargs={ + "game_id": self._game_presenter.game_id, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: return "".join( diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index 281156d..0fbe087 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -17,11 +17,11 @@ views.lichess_correspondence_game, name="correspondence_game", ), - # path( - # "htmx/games/correspondence//no-selection/", - # views.htmx_game_no_selection, - # name="htmx_game_no_selection", - # ), + path( + "htmx/games/correspondence//no-selection/", + views.htmx_lichess_correspondence_game_no_selection, + name="htmx_game_no_selection", + ), path( "htmx/games/correspondence//pieces//select/", views.htmx_game_select_piece, diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 34b1855..bdce116 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -76,7 +76,7 @@ async def lichess_home( @with_lichess_access_token @redirect_if_no_lichess_access_token async def lichess_game_create( - request: "HttpRequest", lichess_access_token: "LichessAccessToken" + request: "HttpRequest", *, lichess_access_token: "LichessAccessToken" ) -> HttpResponse: form_errors = {} if request.method == "POST": @@ -108,14 +108,15 @@ async def lichess_game_create( @redirect_if_no_lichess_access_token async def lichess_correspondence_game( request: "HttpRequest", + *, lichess_access_token: "LichessAccessToken", game_id: "LichessGameId", ) -> HttpResponse: me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( game_data=game_data, - refresh_last_move=True, is_htmx_request=False, + refresh_last_move=True, ) return HttpResponse( @@ -126,6 +127,27 @@ async def lichess_correspondence_game( ) +@require_safe +@with_lichess_access_token +@redirect_if_no_lichess_access_token +async def htmx_lichess_correspondence_game_no_selection( + request: "HttpRequest", + *, + lichess_access_token: "LichessAccessToken", + game_id: "LichessGameId", +) -> HttpResponse: + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + is_htmx_request=True, + refresh_last_move=False, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + @require_safe @with_lichess_access_token @redirect_if_no_lichess_access_token diff --git a/tailwind.config.js b/tailwind.config.js index ebeac28..30438e0 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,8 +8,8 @@ const PIECE_NAMES = ["pawn", "knight", "bishop", "rook", "queen", "king"] const PLAYER_SIDES = ["w", "b"] const FACTIONS = ["humans", "undeads"] -const ACTIVE_PLAYER_SELECTION_COLOR = "#ffff00" -const OPPONENT_PLAYER_SELECTION_COLOR = "#ffd000" +const PLAYABLE_SELECTION_COLOR = "#ffff00" +const NON_PLAYABLE_SELECTION_COLOR = "#ffd000" const POTENTIAL_CAPTURE_COLOR = "#c00000" const PIECE_SYMBOL_BORDER_OPACITY = Math.round(0.4 * 0xff).toString(16) // 40% of 255 const PIECE_SYMBOL_W = `#065f46${PIECE_SYMBOL_BORDER_OPACITY}` // emerald-800 @@ -37,8 +37,8 @@ module.exports = { "chess-square-dark": "#a57713", // "#881337", // Amber 700 // "#a57713", // "#9f1239", "chess-square-square-info": "#58400b", "body-background": "#120222", // @link https://www.tints.dev/purple/A855F7 - "active-chess-available-target-marker": ACTIVE_PLAYER_SELECTION_COLOR, - "opponent-chess-available-target-marker": OPPONENT_PLAYER_SELECTION_COLOR, + "playable-chess-available-target-marker": PLAYABLE_SELECTION_COLOR, + "non-playable-chess-available-target-marker": NON_PLAYABLE_SELECTION_COLOR, }, width: { "1/8": "12.5%", @@ -86,13 +86,10 @@ module.exports = { // "piece-symbol-b": `0 0 0.1rem ${PIECE_SYMBOL_B}`, "piece-symbol-w": borderFromDropShadow(1, PIECE_SYMBOL_W), "piece-symbol-b": borderFromDropShadow(1, PIECE_SYMBOL_B), - "active-selected-piece": borderFromDropShadow( + "playable-selected-piece": borderFromDropShadow(PIECES_DROP_SHADOW_OFFSET, PLAYABLE_SELECTION_COLOR), + "non-playable-selected-piece": borderFromDropShadow( PIECES_DROP_SHADOW_OFFSET, - ACTIVE_PLAYER_SELECTION_COLOR, - ), - "opponent-selected-piece": borderFromDropShadow( - PIECES_DROP_SHADOW_OFFSET, - OPPONENT_PLAYER_SELECTION_COLOR, + NON_PLAYABLE_SELECTION_COLOR, ), "potential-capture": borderFromDropShadow(PIECES_DROP_SHADOW_OFFSET, POTENTIAL_CAPTURE_COLOR), "speech-bubble": `0 0 2px ${SPEECH_BUBBLE_DROP_SHADOW_COLOR}`, From 06d8c2d9fcf59f66ab493df90458f9ee9e62cc9b Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Thu, 12 Sep 2024 12:36:24 +0100 Subject: [PATCH 24/42] [lichess] Use "game stream" endpoint instead of the "game export" one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For some reason, it seems that the "game export" ednpoint returns outdated information about the ongoing games. It was returning up-to-date data until yesterday, but for a mysterious reason it stopped doing so 🤷 Maybe it returns only the few first moves, when the game is still ongoing? Anyway... Thankfully, it seems that using the "Stream Board game state" endpoint works correctly. 🤞🤞🤞 Also improve code there and there while I was here :-) --- src/apps/chess/chess_helpers.py | 9 +- src/apps/chess/types.py | 4 + .../business_logic/__init__.py | 1 + src/apps/daily_challenge/cookie_helpers.py | 41 +-- src/apps/daily_challenge/view_helpers.py | 4 +- src/apps/daily_challenge/views.py | 4 +- .../lichess_bridge/business_logic/__init__.py | 10 +- .../_rebuild_game_from_moves.py | 70 +++++ ..._position.py => _rebuild_game_from_pgn.py} | 14 +- .../components/pages/lichess_pages.py | 2 +- src/apps/lichess_bridge/lichess_api.py | 96 +++++-- src/apps/lichess_bridge/models.py | 242 +++++++++++++++--- src/apps/lichess_bridge/presenters.py | 19 +- src/apps/lichess_bridge/tests/test_views.py | 52 +++- src/apps/lichess_bridge/urls.py | 7 +- src/apps/lichess_bridge/views.py | 102 ++++++-- src/apps/lichess_bridge/views_decorators.py | 19 ++ src/apps/webui/components/layout.py | 4 + src/apps/webui/cookie_helpers.py | 50 ++++ src/project/settings/_base.py | 1 + 20 files changed, 598 insertions(+), 153 deletions(-) create mode 100644 src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py rename src/apps/lichess_bridge/business_logic/{_rebuild_game_from_starting_position.py => _rebuild_game_from_pgn.py} (87%) create mode 100644 src/apps/webui/cookie_helpers.py diff --git a/src/apps/chess/chess_helpers.py b/src/apps/chess/chess_helpers.py index b4e797a..f22878e 100644 --- a/src/apps/chess/chess_helpers.py +++ b/src/apps/chess/chess_helpers.py @@ -1,4 +1,4 @@ -from functools import cache, lru_cache +from functools import cache from typing import TYPE_CHECKING, cast import chess @@ -9,7 +9,6 @@ PIECE_TYPE_TO_NAME, PIECE_TYPE_TO_UNICODE, RANKS, - SQUARES, ) if TYPE_CHECKING: @@ -145,16 +144,10 @@ def get_active_player_side_from_chess_board(board: chess.Board) -> "PlayerSide": return "w" if board.turn else "b" -@lru_cache def uci_move_squares(move: str) -> tuple["Square", "Square"]: return cast("Square", move[:2]), cast("Square", move[2:4]) -@cache -def get_square_order(square: "Square") -> int: - return SQUARES.index(square) - - @cache def player_side_to_chess_lib_color(player_side: "PlayerSide") -> chess.Color: return chess.WHITE if player_side == "w" else chess.BLACK diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index c1ba07b..902fb5e 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -3,6 +3,10 @@ if TYPE_CHECKING: from .models import TeamMember +# Apart from cases when we use these types in msgspec models (the package will need their +# "real" imports to be able to work with them), the types defined here should always +# be used in `if TYPE_CHECKING:` blocks. + # https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation FEN: TypeAlias = str # https://en.wikipedia.org/wiki/Portable_Game_Notation diff --git a/src/apps/daily_challenge/business_logic/__init__.py b/src/apps/daily_challenge/business_logic/__init__.py index 7c91bd6..b021846 100644 --- a/src/apps/daily_challenge/business_logic/__init__.py +++ b/src/apps/daily_challenge/business_logic/__init__.py @@ -19,3 +19,4 @@ from ._set_daily_challenge_teams_and_pieces_roles import ( set_daily_challenge_teams_and_pieces_roles, ) +from ._undo_last_move import undo_last_move diff --git a/src/apps/daily_challenge/cookie_helpers.py b/src/apps/daily_challenge/cookie_helpers.py index 25b3259..ade62d6 100644 --- a/src/apps/daily_challenge/cookie_helpers.py +++ b/src/apps/daily_challenge/cookie_helpers.py @@ -1,32 +1,19 @@ -import datetime as dt import logging from typing import TYPE_CHECKING, NamedTuple from django.utils.timezone import now from msgspec import MsgspecError -from apps.chess.models import UserPrefs -from lib.http_cookies_helpers import ( - HttpCookieAttributes, - set_http_cookie_on_django_response, -) - from .models import PlayerGameState, PlayerSessionContent, PlayerStats if TYPE_CHECKING: - from django.http import HttpRequest, HttpResponse + from django.http import HttpRequest from .models import DailyChallenge _PLAYER_CONTENT_SESSION_KEY = "pc" -_USER_PREFS_COOKIE_ATTRS = HttpCookieAttributes( - name="uprefs", - max_age=dt.timedelta(days=30 * 6), # approximately 6 months - http_only=True, - same_site="Lax", -) _logger = logging.getLogger(__name__) @@ -104,24 +91,6 @@ def new_content(): return new_content() -def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs: - def new_content(): - return UserPrefs() - - cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_ATTRS.name) - if cookie_content is None or len(cookie_content) < 5: - return new_content() - - try: - user_prefs = UserPrefs.from_cookie_content(cookie_content) - return user_prefs - except MsgspecError: - _logger.exception( - "Could not decode cookie content; restarting with a blank one." - ) - return new_content() - - def save_daily_challenge_state_in_session( *, request: "HttpRequest", game_state: PlayerGameState, player_stats: PlayerStats ) -> None: @@ -133,14 +102,6 @@ def save_daily_challenge_state_in_session( _store_player_session_content(request, session_content) -def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None: - set_http_cookie_on_django_response( - response=response, - attributes=_USER_PREFS_COOKIE_ATTRS, - value=user_prefs.to_cookie_content(), - ) - - def clear_daily_challenge_game_state_in_session( *, request: "HttpRequest", player_stats: PlayerStats ) -> None: diff --git a/src/apps/daily_challenge/view_helpers.py b/src/apps/daily_challenge/view_helpers.py index 9a06ce7..6f37d40 100644 --- a/src/apps/daily_challenge/view_helpers.py +++ b/src/apps/daily_challenge/view_helpers.py @@ -1,6 +1,8 @@ import dataclasses from typing import TYPE_CHECKING, cast +from apps.webui.cookie_helpers import get_user_prefs_from_request + from . import cookie_helpers from .business_logic import manage_new_daily_challenge_stats_logic @@ -40,7 +42,7 @@ def create_from_request(cls, request: "HttpRequest") -> "GameContext": request=request, challenge=challenge ) ) - user_prefs = cookie_helpers.get_user_prefs_from_request(request) + user_prefs = get_user_prefs_from_request(request) # TODO: validate the "board_id" data? board_id = cast(str, request.GET.get("board_id", "main")) diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index a68d54a..980e62b 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -12,6 +12,7 @@ from apps.chess.types import ChessInvalidActionException, ChessInvalidMoveException from apps.utils.view_decorators import user_is_staff from apps.utils.views_helpers import htmx_aware_redirect +from apps.webui.cookie_helpers import save_user_prefs from .business_logic import ( manage_daily_challenge_defeat_logic, @@ -20,8 +21,8 @@ move_daily_challenge_piece, restart_daily_challenge, see_daily_challenge_solution, + undo_last_move, ) -from .business_logic._undo_last_move import undo_last_move from .components.misc_ui.help_modal import help_modal from .components.misc_ui.stats_modal import stats_modal from .components.misc_ui.user_prefs_modal import user_prefs_modal @@ -33,7 +34,6 @@ clear_daily_challenge_game_state_in_session, get_or_create_daily_challenge_state_for_player, save_daily_challenge_state_in_session, - save_user_prefs, ) from .forms import UserPrefsForm from .models import PlayerGameOverState diff --git a/src/apps/lichess_bridge/business_logic/__init__.py b/src/apps/lichess_bridge/business_logic/__init__.py index 021d49e..e82fc65 100644 --- a/src/apps/lichess_bridge/business_logic/__init__.py +++ b/src/apps/lichess_bridge/business_logic/__init__.py @@ -3,7 +3,11 @@ from ._create_teams_and_piece_role_by_square_for_starting_position import ( create_teams_and_piece_role_by_square_for_starting_position, ) -from ._rebuild_game_from_starting_position import ( - RebuildGameFromStartingPositionResult, - rebuild_game_from_starting_position, +from ._rebuild_game_from_moves import ( + RebuildGameFromMovesResult, + rebuild_game_from_moves, +) +from ._rebuild_game_from_pgn import ( + RebuildGameFromPgnResult, + rebuild_game_from_pgn, ) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py new file mode 100644 index 0000000..08e08db --- /dev/null +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py @@ -0,0 +1,70 @@ +import logging +import time +from typing import TYPE_CHECKING, NamedTuple + +import chess + +from ...chess.business_logic import do_chess_move_with_piece_role_by_square +from ...chess.chess_helpers import uci_move_squares + +if TYPE_CHECKING: + from collections.abc import Sequence + + import chess.pgn + + from apps.chess.types import PieceRoleBySquare, UCIMove + + from ...chess.models import GameFactions, GameTeams + +_logger = logging.getLogger(__name__) + + +class RebuildGameFromMovesResult(NamedTuple): + chess_board: chess.Board + teams: "GameTeams" + piece_role_by_square: "PieceRoleBySquare" + moves: "Sequence[UCIMove]" + + +def rebuild_game_from_moves( + *, uci_moves: "Sequence[UCIMove]", factions: "GameFactions" +) -> RebuildGameFromMovesResult: + from ._create_teams_and_piece_role_by_square_for_starting_position import ( + create_teams_and_piece_role_by_square_for_starting_position, + ) + + start_time = time.monotonic() + + # We start with a "starting position "chess board"... + teams, piece_role_by_square_tuple = ( + create_teams_and_piece_role_by_square_for_starting_position(factions) + ) + piece_role_by_square = dict(piece_role_by_square_tuple) + + # ...and then we apply the moves from the game data to it, one by one: + # (while keeping track of the piece roles on the board, so if "p1" moves, + # we can "follow" that pawn) + chess_board = chess.Board() + for move in uci_moves: + from_, to = uci_move_squares(move) + move_result, piece_role_by_square, captured_piece = ( + do_chess_move_with_piece_role_by_square( + from_=from_, + to=to, + piece_role_by_square=piece_role_by_square, + chess_board=chess_board, + ) + ) + + _logger.info( + "`rebuild_game_from_moves` took %d ms. for %d moves", + (time.monotonic() - start_time) * 1000, + len(uci_moves), + ) + + return RebuildGameFromMovesResult( + chess_board=chess_board, + teams=teams, + piece_role_by_square=piece_role_by_square, + moves=uci_moves, + ) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py similarity index 87% rename from src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py rename to src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py index 1102a4c..27d2ce0 100644 --- a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_starting_position.py +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py @@ -8,6 +8,8 @@ from ...chess.chess_helpers import chess_lib_square_to_square if TYPE_CHECKING: + from collections.abc import Sequence + import chess.pgn from apps.chess.types import PieceRoleBySquare, UCIMove @@ -17,16 +19,16 @@ _logger = logging.getLogger(__name__) -class RebuildGameFromStartingPositionResult(NamedTuple): +class RebuildGameFromPgnResult(NamedTuple): chess_board: chess.Board teams: "GameTeams" piece_role_by_square: "PieceRoleBySquare" - moves: list["UCIMove"] + moves: "Sequence[UCIMove]" -def rebuild_game_from_starting_position( +def rebuild_game_from_pgn( *, pgn_game: "chess.pgn.Game", factions: "GameFactions" -) -> RebuildGameFromStartingPositionResult: +) -> RebuildGameFromPgnResult: from ._create_teams_and_piece_role_by_square_for_starting_position import ( create_teams_and_piece_role_by_square_for_starting_position, ) @@ -60,12 +62,12 @@ def rebuild_game_from_starting_position( uci_moves.append(move.uci()) _logger.info( - "`rebuild_game_from_starting_position` took %d ms. for %d moves", + "`rebuild_game_from_pgn` took %d ms. for %d moves", (time.monotonic() - start_time) * 1000, len(uci_moves), ) - return RebuildGameFromStartingPositionResult( + return RebuildGameFromPgnResult( chess_board=chess_board, teams=teams, piece_role_by_square=piece_role_by_square, diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 42cd246..83d173d 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -72,7 +72,7 @@ def lichess_no_account_linked_page( ) -def lichess_account_linked_homepage( +def lichess_my_current_games_list_page( *, request: "HttpRequest", me: "LichessAccountInformation", diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 47b5bf7..447e57a 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -14,6 +14,7 @@ LICHESS_ACCESS_TOKEN_PREFIX, LichessAccountInformation, LichessGameExport, + LichessGameFullFromStream, LichessOngoingGameData, ) @@ -32,8 +33,13 @@ "DURATION": dt.timedelta(seconds=120).total_seconds(), } -_GET_GAME_BY_ID_CACHE = { - "KEY_PATTERN": "lichess_bridge::get_game_by_id::{game_id}", +_GET_GAME_BY_ID_FROM_STREAM_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_game_by_id_from_stream::{game_id}", + "DURATION": dt.timedelta(seconds=30).total_seconds(), +} + +_GET_EXPORT_BY_ID_CACHE = { + "KEY_PATTERN": "lichess_bridge::get_game_export_by_id::{game_id}", "DURATION": dt.timedelta(seconds=30).total_seconds(), } @@ -90,34 +96,97 @@ class ResponseDataWrapper(msgspec.Struct): return msgspec.json.decode(response.content, type=ResponseDataWrapper).nowPlaying -async def get_game_by_id( - *, api_client: httpx.AsyncClient, game_id: "LichessGameId" +async def get_game_export_by_id( + *, + api_client: httpx.AsyncClient, + game_id: "LichessGameId", + try_fetching_from_cache: bool = True, ) -> LichessGameExport: """ This is cached for a short amount of time, so we don't re-fetch the same games again while the player is selecting pieces. """ - cache_key = _GET_GAME_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + cache_key = _GET_EXPORT_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] game_id=game_id, ) - if cached_data := await cache.aget(cache_key): - _logger.info("Using cached data for 'get_game_by_id'.") - response_content = cached_data - else: + + response_content: bytes | None = None + if try_fetching_from_cache: + if cached_data := await cache.aget(cache_key): + _logger.info("Using cached data for 'get_game_export_by_id'.") + response_content = cached_data + + if not response_content: # https://lichess.org/api#tag/Games/operation/gamePgn + # An important aspect to keep in mind: + # > Ongoing games are delayed by a few seconds ranging from 3 to 60 + # > depending on the time control, as to prevent cheat bots from using this API. endpoint = f"/game/export/{game_id}" with _lichess_api_monitoring("GET", endpoint): # We only need the FEN, but it seems that the Lichess "game by ID" API endpoints # can only return the full PGN - which will require a bit more work to parse. - response = await api_client.get(endpoint, params={"pgnInJson": "true"}) + response = await api_client.get( + endpoint, + params={"pgnInJson": "1", "tags": "0", "moves": "0", "evals": "0"}, + ) response.raise_for_status() response_content = response.content - await cache.aset(cache_key, response_content, _GET_GAME_BY_ID_CACHE["DURATION"]) + await cache.aset( + cache_key, response_content, _GET_EXPORT_BY_ID_CACHE["DURATION"] + ) return msgspec.json.decode(response_content, type=LichessGameExport) +async def get_game_by_id_from_stream( + *, + api_client: httpx.AsyncClient, + game_id: "LichessGameId", + try_fetching_from_cache: bool = True, +) -> LichessGameFullFromStream: + """ + This is cached for a short amount of time, so we don't re-fetch the same games again + while the player is selecting pieces. + """ + cache_key = _GET_GAME_BY_ID_FROM_STREAM_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + + response_content: str | None = None + if try_fetching_from_cache: + if cached_data := await cache.aget(cache_key): + _logger.info("Using cached data for 'get_game_by_id_from_stream'.") + response_content = cached_data + + if not response_content: + # https://lichess.org/api#tag/Board/operation/boardGameStream + endpoint = f"/api/board/game/stream/{game_id}" + with _lichess_api_monitoring("GET (stream)", endpoint): + async with api_client.stream("GET", endpoint) as response: + async for line in response.aiter_lines(): + if line and '"gameFull"' in line: + response_content = line + # We got what we need, let's break that loop - HTTPX will + # automatically close the stream as we exit the `.stream` block. + break + response.raise_for_status() + + await cache.aset( + cache_key, response_content, _GET_GAME_BY_ID_FROM_STREAM_CACHE["DURATION"] + ) + + assert type(response_content) == str # for type checkers + return msgspec.json.decode(response_content, type=LichessGameFullFromStream) + + +async def clear_game_by_id_cache(game_id: "LichessGameId") -> None: + get_game_by_id_cache_key = _GET_EXPORT_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + await cache.adelete(get_game_by_id_cache_key) + + async def move_lichess_game_piece( *, api_client: httpx.AsyncClient, @@ -139,10 +208,7 @@ async def move_lichess_game_piece( ) response.raise_for_status() - get_game_by_id_cache_key = _GET_GAME_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] - game_id=game_id, - ) - cache.delete(get_game_by_id_cache_key) + await clear_game_by_id_cache(game_id) return response.json()["ok"] diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 2752907..15e81eb 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -1,6 +1,7 @@ import dataclasses import functools import io +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias import chess.pgn @@ -10,9 +11,11 @@ from apps.chess.models import GameFactions from apps.chess.types import FEN -from .business_logic import rebuild_game_from_starting_position +from .business_logic import rebuild_game_from_moves, rebuild_game_from_pgn if TYPE_CHECKING: + from collections.abc import Sequence + import chess from apps.chess.models import GameTeams @@ -23,7 +26,7 @@ UCIMove, ) - from .business_logic import RebuildGameFromStartingPositionResult + from .business_logic import RebuildGameFromMovesResult, RebuildGameFromPgnResult LichessAccessToken: TypeAlias = str # e.g. "lio_6EeGimHMalSVH9qMcfUc2JJ3xdBPlqrL" @@ -200,46 +203,186 @@ class LichessGameExport(msgspec.Struct): division: dict # ??? -class LichessGameExportMetadataPlayerSides(NamedTuple): - me: "LichessPlayerSide" - them: "LichessPlayerSide" +class LichessVariantStruct(msgspec.Struct): + key: str # e.g. "standard" + name: str # e.g. "Standard" + short: str # e.g. "Std" + +class LichessPerfStruct(msgspec.Struct): + name: str # Translated perf name (e.g. "Correspondence", "Classical" or "Blitz") -class LichessGameExportMetadataPlayer(NamedTuple): + +class LichessGameEventPlayer(msgspec.Struct): id: LichessPlayerId - username: LichessGameFullId - player_side: "PlayerSide" - faction: "Faction" + name: LichessGameFullId + rating: int + title: str | None = None # ?? + provisional: bool | None = None -class LichessGameExportMetadataPlayers(NamedTuple): - """ - Information about the players of a game, structured in a "me" and "them" way, and - giving us the "active player" as well. +class LichessGameState(msgspec.Struct): + type: str # e.g. "gameState" + moves: str # e.g. "e2e4 d7d5 f2f3 e7e5 f1d3 f8c5 b2b3" + wtime: int # e.g. 259200000 + btime: int # e.g. 255151000 + winc: int # e.g. 0 + binc: int # e.g. 0 + status: LichessGameStatus - (as opposed to the "players" field in the LichessGameExport class, which tells us - who the "white" and "black" players are but without telling us which one is "me", - or which one is the current active player) + +class LichessGameFullFromStream(msgspec.Struct): + id: str + variant: LichessVariantStruct + speed: str + perf: LichessPerfStruct + rated: bool + createdAt: int + white: LichessGameEventPlayer + black: LichessGameEventPlayer + initialFen: FEN | Literal["startpos"] + daysPerTurn: int + type: Literal["gameFull"] + state: LichessGameState + + +class LichessGameWithMetadataBase(ABC): + @property + @abstractmethod + def chess_board(self) -> "chess.Board": ... + + @property + @abstractmethod + def moves(self) -> "Sequence[UCIMove]": ... + + @property + @abstractmethod + def piece_role_by_square(self) -> "PieceRoleBySquare": ... + + @property + @abstractmethod + def teams(self) -> "GameTeams": ... + + @functools.cached_property + def active_player_side(self) -> "LichessPlayerSide": + return "white" if self.chess_board.turn else "black" + + @property + @abstractmethod + def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": ... + + @functools.cached_property + def game_factions(self) -> GameFactions: + my_side = self._players_sides[0] + # For now we hard-code the fact that "me" always plays the "humans" faction, + # and "them" always plays the "undeads" faction. + factions: "tuple[Faction, Faction]" = ( + "humans", + "undeads", + ) + if my_side == "white": + w_faction, b_faction = factions + else: + b_faction, w_faction = factions + + return GameFactions( + w=w_faction, + b=b_faction, + ) + + @property + @abstractmethod + def _players_sides(self) -> "LichessGameMetadataPlayerSides": ... + + +@dataclasses.dataclass(frozen=True) +class LichessGameFullFromStreamWithMetadata(LichessGameWithMetadataBase): + """ + Wraps a LichessGameFullFromStream object with some additional metadata related to the + current player, and some cached properties describing the state of the game. """ - me: LichessGameExportMetadataPlayer - them: LichessGameExportMetadataPlayer - active_player: Literal["me", "them"] + raw_data: LichessGameFullFromStream + my_player_id: "LichessPlayerId" + + @functools.cached_property + def chess_board(self) -> "chess.Board": + return self._rebuilt_game.chess_board + + @functools.cached_property + def moves(self) -> "Sequence[UCIMove]": + return self._rebuilt_game.moves + + @functools.cached_property + def piece_role_by_square(self) -> "PieceRoleBySquare": + return self._rebuilt_game.piece_role_by_square + + @functools.cached_property + def teams(self) -> "GameTeams": + return self._rebuilt_game.teams + + @functools.cached_property + def active_player_side(self) -> "LichessPlayerSide": + return "white" if self.chess_board.turn else "black" + + @functools.cached_property + def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": + my_side, their_side = self._players_sides + + my_player: "LichessGameEventPlayer" = getattr(self.raw_data, my_side) + their_player: "LichessGameEventPlayer" = getattr(self.raw_data, their_side) + + result = LichessGameMetadataPlayers( + me=LichessGameMetadataPlayer( + id=my_player.id, + username=my_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], + faction="humans", + ), + them=LichessGameMetadataPlayer( + id=their_player.id, + username=their_player.name, + player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], + faction="undeads", + ), + active_player="me" if self.active_player_side == my_side else "them", + ) + + return result + + @functools.cached_property + def _players_sides(self) -> "LichessGameMetadataPlayerSides": + my_side: "LichessPlayerSide" = ( + "white" if self.raw_data.white.id == self.my_player_id else "black" + ) + their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + + return LichessGameMetadataPlayerSides( + me=my_side, + them=their_side, + ) + + @functools.cached_property + def _rebuilt_game(self) -> "RebuildGameFromMovesResult": + return rebuild_game_from_moves( + uci_moves=self.raw_data.state.moves.strip().split(" "), + factions=self.game_factions, + ) -@dataclasses.dataclass -class LichessGameExportWithMetadata: +@dataclasses.dataclass(frozen=True) +class LichessGameExportWithMetadata(LichessGameWithMetadataBase): """ Wraps a LichessGameExport object with some additional metadata related to the - current player. + current player, and some cached properties describing the state of the game. """ - game_export: LichessGameExport + raw_data: LichessGameExport my_player_id: "LichessPlayerId" @functools.cached_property def pgn_game(self) -> "chess.pgn.Game": - pgn_game = chess.pgn.read_game(io.StringIO(self.game_export.pgn)) + pgn_game = chess.pgn.read_game(io.StringIO(self.raw_data.pgn)) if not pgn_game: raise ValueError("Could not read PGN game") return pgn_game @@ -249,7 +392,7 @@ def chess_board(self) -> "chess.Board": return self._rebuilt_game.chess_board @functools.cached_property - def moves(self) -> "list[UCIMove]": + def moves(self) -> "Sequence[UCIMove]": return self._rebuilt_game.moves @functools.cached_property @@ -265,22 +408,22 @@ def active_player_side(self) -> "LichessPlayerSide": return "white" if self.chess_board.turn else "black" @functools.cached_property - def players_from_my_perspective(self) -> "LichessGameExportMetadataPlayers": + def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": my_side, their_side = self._players_sides - my_player: "LichessGameUser" = getattr(self.game_export.players, my_side).user + my_player: "LichessGameUser" = getattr(self.raw_data.players, my_side).user their_player: "LichessGameUser" = getattr( - self.game_export.players, their_side + self.raw_data.players, their_side ).user - result = LichessGameExportMetadataPlayers( - me=LichessGameExportMetadataPlayer( + result = LichessGameMetadataPlayers( + me=LichessGameMetadataPlayer( id=my_player.id, username=my_player.name, player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[my_side], faction="humans", ), - them=LichessGameExportMetadataPlayer( + them=LichessGameMetadataPlayer( id=their_player.id, username=their_player.name, player_side=_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING[their_side], @@ -311,21 +454,48 @@ def game_factions(self) -> GameFactions: ) @functools.cached_property - def _players_sides(self) -> LichessGameExportMetadataPlayerSides: + def _players_sides(self) -> "LichessGameMetadataPlayerSides": my_side: "LichessPlayerSide" = ( "white" - if self.game_export.players.white.user.id == self.my_player_id + if self.raw_data.players.white.user.id == self.my_player_id else "black" ) their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" - return LichessGameExportMetadataPlayerSides( + return LichessGameMetadataPlayerSides( me=my_side, them=their_side, ) @functools.cached_property - def _rebuilt_game(self) -> "RebuildGameFromStartingPositionResult": - return rebuild_game_from_starting_position( + def _rebuilt_game(self) -> "RebuildGameFromPgnResult": + return rebuild_game_from_pgn( pgn_game=self.pgn_game, factions=self.game_factions ) + + +class LichessGameMetadataPlayerSides(NamedTuple): + me: "LichessPlayerSide" + them: "LichessPlayerSide" + + +class LichessGameMetadataPlayer(NamedTuple): + id: LichessPlayerId + username: LichessGameFullId + player_side: "PlayerSide" + faction: "Faction" + + +class LichessGameMetadataPlayers(NamedTuple): + """ + Information about the players of a game, structured in a "me" and "them" way, and + giving us the "active player" as well. + + (as opposed to the "players" field in the LichessGameExport class, which tells us + who the "white" and "black" players are but without telling us which one is "me", + or which one is the current active player) + """ + + me: LichessGameMetadataPlayer + them: LichessGameMetadataPlayer + active_player: Literal["me", "them"] diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index f1e3813..880542f 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -4,11 +4,12 @@ from django.urls import reverse +from apps.chess.chess_helpers import uci_move_squares +from apps.chess.models import GameFactions from apps.chess.presenters import GamePresenter, GamePresenterUrls -from ..chess.models import GameFactions - if TYPE_CHECKING: + from apps.chess.models import UserPrefs from apps.chess.presenters import SpeechBubbleData from apps.chess.types import ( FEN, @@ -19,24 +20,29 @@ Square, ) - from .models import LichessGameExportWithMetadata + from .models import LichessGameFullFromStreamWithMetadata class LichessCorrespondenceGamePresenter(GamePresenter): def __init__( self, *, - game_data: "LichessGameExportWithMetadata", + game_data: "LichessGameFullFromStreamWithMetadata", refresh_last_move: bool, is_htmx_request: bool, selected_piece_square: "Square | None" = None, - last_move: tuple["Square", "Square"] | None = None, + user_prefs: "UserPrefs | None" = None, ): self._game_data = game_data self._chess_board = game_data.chess_board fen = cast("FEN", self._chess_board.fen()) + if self._game_data.moves: + last_move = uci_move_squares(self._game_data.moves[-1]) + else: + last_move = None + super().__init__( fen=fen, piece_role_by_square=game_data.piece_role_by_square, @@ -45,6 +51,7 @@ def __init__( is_htmx_request=is_htmx_request, selected_piece_square=selected_piece_square, last_move=last_move, + user_prefs=user_prefs, ) @cached_property @@ -88,7 +95,7 @@ def solution_index(self) -> int | None: @cached_property def game_id(self) -> str: - return self._game_data.game_export.id + return self._game_data.raw_data.id @cached_property def factions(self) -> GameFactions: diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 7ffa49a..d1027d5 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -1,3 +1,4 @@ +import contextlib import json from http import HTTPStatus from typing import TYPE_CHECKING, Any @@ -120,7 +121,41 @@ async def test_lichess_correspondence_game_without_access_token_should_redirect( assert response.status_code == HTTPStatus.FOUND -_LICHESS_CORRESPONDENCE_GAME_JSON_RESPONSE = { +_LICHESS_CORRESPONDENCE_GAME_FROM_STREAM_JSON_RESPONSE = { + "id": "tFfGsEpb", + "variant": {"key": "standard", "name": "Standard", "short": "Std"}, + "speed": "correspondence", + "perf": {"name": "Correspondence"}, + "rated": False, + "createdAt": 1725637044590, + "white": { + "id": "chesschampion", + "name": "ChessChampion", + "title": None, + "rating": 1500, + "provisional": True, + }, + "black": { + "id": "chessmaster74960", + "name": "ChessMaster74960", + "title": None, + "rating": 2078, + }, + "initialFen": "startpos", + "daysPerTurn": 3, + "type": "gameFull", + "state": { + "type": "gameState", + "moves": "e2e4 d7d5 f2f3 e7e5 f1d3 f8c5 b2b3", + "wtime": 259200000, + "btime": 255151000, + "winc": 0, + "binc": 0, + "status": "started", + }, +} + +_LICHESS_CORRESPONDENCE_GAME_EXPORT_JSON_RESPONSE = { "id": "tFfGsEpb", "fullId": "tFfGsEpbd0mL", "rated": False, @@ -194,17 +229,26 @@ def content(self) -> str: "url": "https://lichess.org/@/chesschampion", "username": "ChessChampion", } - case "/game/export/tFfGsEpb": - result = _LICHESS_CORRESPONDENCE_GAME_JSON_RESPONSE case _: raise ValueError(f"Unexpected path: {self.path}") return json.dumps(result) + async def aiter_lines(self): + yield json.dumps(_LICHESS_CORRESPONDENCE_GAME_FROM_STREAM_JSON_RESPONSE) + raise ValueError( + "aiterlines should be stopped after the 1st line was read" + ) + async def get(self, path, **kwargs): # The client's `get` method is async - assert path.startswith(("/api/", "/game/export/")) + assert path.startswith("/api/") return self.HttpClientResponseMock(path) + @contextlib.asynccontextmanager + async def stream(self, method, path, **kwargs): + assert method == "GET" and path.startswith("/api/board/game/stream/") + yield self.HttpClientResponseMock(path) + with mock.patch( "apps.lichess_bridge.lichess_api._create_lichess_api_client", ) as create_lichess_api_client_mock: diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index 0fbe087..d7f1225 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -5,8 +5,9 @@ app_name = "lichess_bridge" urlpatterns = [ - path("", views.lichess_home, name="homepage"), - # Game management Views: + path("", views.lichess_home_page, name="homepage"), + path("games/", views.lichess_my_games_list_page, name="my_games"), + # Gameplay Views: path( "games/new/", views.lichess_game_create, @@ -14,7 +15,7 @@ ), path( "games/correspondence//", - views.lichess_correspondence_game, + views.lichess_correspondence_game_page, name="correspondence_game", ), path( diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index bdce116..133c449 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -19,17 +19,19 @@ from .components.pages import lichess_pages as lichess_pages from .components.pages.lichess_pages import lichess_game_moving_parts_fragment from .forms import LichessCorrespondenceGameCreationForm -from .models import LichessGameExportWithMetadata +from .models import LichessGameFullFromStreamWithMetadata from .presenters import LichessCorrespondenceGamePresenter from .views_decorators import ( handle_chess_logic_exceptions, redirect_if_no_lichess_access_token, with_lichess_access_token, + with_user_prefs, ) if TYPE_CHECKING: from django.http import HttpRequest + from apps.chess.models import UserPrefs from apps.chess.types import ChessInvalidMoveException, Square from .models import ( @@ -45,33 +47,34 @@ @require_safe @with_lichess_access_token -async def lichess_home( +async def lichess_home_page( request: "HttpRequest", lichess_access_token: "LichessAccessToken | None" ) -> HttpResponse: if not lichess_access_token: page_content = lichess_pages.lichess_no_account_linked_page(request=request) else: - async with lichess_api.get_lichess_api_client( - access_token=lichess_access_token - ) as lichess_api_client: - # As the queries are unrelated, let's run them in parallel: - async with asyncio.TaskGroup() as tg: - me = tg.create_task( - lichess_api.get_my_account(api_client=lichess_api_client) - ) - ongoing_games = tg.create_task( - lichess_api.get_my_ongoing_games(api_client=lichess_api_client) - ) - - page_content = lichess_pages.lichess_account_linked_homepage( + page_content = await _get_my_games_list_page_content( request=request, - me=me.result(), - ongoing_games=ongoing_games.result(), + lichess_access_token=lichess_access_token, ) return HttpResponse(page_content) +@require_safe +@with_lichess_access_token +@redirect_if_no_lichess_access_token +async def lichess_my_games_list_page( + request: "HttpRequest", lichess_access_token: "LichessAccessToken" +) -> HttpResponse: + page_content = await _get_my_games_list_page_content( + request=request, + lichess_access_token=lichess_access_token, + ) + + return HttpResponse(page_content) + + @require_http_methods(["GET", "POST"]) @with_lichess_access_token @redirect_if_no_lichess_access_token @@ -105,18 +108,23 @@ async def lichess_game_create( @require_safe @with_lichess_access_token +@with_user_prefs @redirect_if_no_lichess_access_token -async def lichess_correspondence_game( +async def lichess_correspondence_game_page( request: "HttpRequest", *, lichess_access_token: "LichessAccessToken", game_id: "LichessGameId", + user_prefs: "UserPrefs | None", ) -> HttpResponse: - me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + me, game_data = await _get_game_context_from_lichess( + lichess_access_token, game_id, use_game_cache=False + ) game_presenter = LichessCorrespondenceGamePresenter( game_data=game_data, is_htmx_request=False, refresh_last_move=True, + user_prefs=user_prefs, ) return HttpResponse( @@ -129,18 +137,21 @@ async def lichess_correspondence_game( @require_safe @with_lichess_access_token +@with_user_prefs @redirect_if_no_lichess_access_token async def htmx_lichess_correspondence_game_no_selection( request: "HttpRequest", *, lichess_access_token: "LichessAccessToken", game_id: "LichessGameId", + user_prefs: "UserPrefs | None", ) -> HttpResponse: me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( game_data=game_data, is_htmx_request=True, refresh_last_move=False, + user_prefs=user_prefs, ) return _lichess_game_moving_parts_fragment_response( @@ -150,6 +161,7 @@ async def htmx_lichess_correspondence_game_no_selection( @require_safe @with_lichess_access_token +@with_user_prefs @redirect_if_no_lichess_access_token @handle_chess_logic_exceptions async def htmx_game_select_piece( @@ -158,6 +170,7 @@ async def htmx_game_select_piece( lichess_access_token: "LichessAccessToken", game_id: "LichessGameId", location: "Square", + user_prefs: "UserPrefs | None", ) -> HttpResponse: me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( @@ -165,6 +178,7 @@ async def htmx_game_select_piece( selected_piece_square=location, is_htmx_request=True, refresh_last_move=False, + user_prefs=user_prefs, ) return _lichess_game_moving_parts_fragment_response( @@ -174,6 +188,7 @@ async def htmx_game_select_piece( @require_POST @with_lichess_access_token +@with_user_prefs @redirect_if_no_lichess_access_token @handle_chess_logic_exceptions async def htmx_game_move_piece( @@ -183,6 +198,7 @@ async def htmx_game_move_piece( game_id: "LichessGameId", from_: "Square", to: "Square", + user_prefs: "UserPrefs | None", ) -> HttpResponse: if from_ == to: raise ChessInvalidMoveException("Not a move") @@ -209,20 +225,20 @@ async def htmx_game_move_piece( ) # The move was successful, let's re-fetch the updated game state: # (the cache for this game's data has be cleared by `move_lichess_game_piece`) - game_export = await lichess_api.get_game_by_id( + game_data_raw = await lichess_api.get_game_by_id_from_stream( api_client=lichess_api_client, game_id=game_id ) # TODO: handle end of game after move! - game_data = LichessGameExportWithMetadata( - game_export=game_export, my_player_id=me.id + game_data = LichessGameFullFromStreamWithMetadata( + raw_data=game_data_raw, my_player_id=me.id ) game_presenter = LichessCorrespondenceGamePresenter( game_data=game_data, - last_move=(from_, to), is_htmx_request=True, refresh_last_move=True, + user_prefs=user_prefs, ) return _lichess_game_moving_parts_fragment_response( @@ -312,9 +328,35 @@ def _lichess_game_moving_parts_fragment_response( ) +async def _get_my_games_list_page_content( + *, + request: "HttpRequest", + lichess_access_token: "LichessAccessToken", +) -> str: + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + # As the queries are unrelated, let's run them in parallel: + async with asyncio.TaskGroup() as tg: + me = tg.create_task( + lichess_api.get_my_account(api_client=lichess_api_client) + ) + ongoing_games = tg.create_task( + lichess_api.get_my_ongoing_games(api_client=lichess_api_client) + ) + + return lichess_pages.lichess_my_current_games_list_page( + request=request, + me=me.result(), + ongoing_games=ongoing_games.result(), + ) + + async def _get_game_context_from_lichess( - lichess_access_token: "LichessAccessToken", game_id: "LichessGameId" -) -> tuple["LichessAccountInformation", "LichessGameExportWithMetadata"]: + lichess_access_token: "LichessAccessToken", + game_id: "LichessGameId", + use_game_cache: bool = True, +) -> tuple["LichessAccountInformation", "LichessGameFullFromStreamWithMetadata"]: async with lichess_api.get_lichess_api_client( access_token=lichess_access_token ) as lichess_api_client: @@ -324,11 +366,15 @@ async def _get_game_context_from_lichess( lichess_api.get_my_account(api_client=lichess_api_client) ) game_data_task = tg.create_task( - lichess_api.get_game_by_id( - api_client=lichess_api_client, game_id=game_id + lichess_api.get_game_by_id_from_stream( + api_client=lichess_api_client, + game_id=game_id, + try_fetching_from_cache=use_game_cache, ) ) me, game_data = me_task.result(), game_data_task.result() - return me, LichessGameExportWithMetadata(game_export=game_data, my_player_id=me.id) + return me, LichessGameFullFromStreamWithMetadata( + raw_data=game_data, my_player_id=me.id + ) diff --git a/src/apps/lichess_bridge/views_decorators.py b/src/apps/lichess_bridge/views_decorators.py index 3d86a54..d8b42bf 100644 --- a/src/apps/lichess_bridge/views_decorators.py +++ b/src/apps/lichess_bridge/views_decorators.py @@ -6,6 +6,7 @@ from django.shortcuts import redirect from ..chess.types import ChessLogicException +from ..webui.cookie_helpers import get_user_prefs_from_request from . import cookie_helpers if TYPE_CHECKING: @@ -40,6 +41,24 @@ def wrapper(request: "HttpRequest", *args, **kwargs): return wrapper +def with_user_prefs(func): + if iscoroutinefunction(func): + + @functools.wraps(func) + async def wrapper(request: "HttpRequest", *args, **kwargs): + user_prefs = get_user_prefs_from_request(request) + return await func(request, *args, user_prefs=user_prefs, **kwargs) + + else: + + @functools.wraps(func) + def wrapper(request: "HttpRequest", *args, **kwargs): + user_prefs = get_user_prefs_from_request(request) + return func(request, *args, user_prefs=user_prefs, **kwargs) + + return wrapper + + def redirect_if_no_lichess_access_token(func): if iscoroutinefunction(func): diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index 673e69c..99c9018 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -9,6 +9,7 @@ from dominate.tags import ( a, body, + comment, div, footer as base_footer, h1, @@ -101,6 +102,9 @@ def document( def head(*children: "dom_tag", title: str) -> "dom_tag": return base_head( + comment( + "ZakuChess is open source! See https://github.com/olivierphi/zakuchess" + ), meta(charset="utf-8"), base_title(title), meta(name="viewport", content="width=device-width, initial-scale=1"), diff --git a/src/apps/webui/cookie_helpers.py b/src/apps/webui/cookie_helpers.py new file mode 100644 index 0000000..c72491d --- /dev/null +++ b/src/apps/webui/cookie_helpers.py @@ -0,0 +1,50 @@ +import datetime as dt +import logging +from typing import TYPE_CHECKING + +from msgspec import MsgspecError + +from apps.chess.models import UserPrefs +from lib.http_cookies_helpers import ( + HttpCookieAttributes, + set_http_cookie_on_django_response, +) + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + +_USER_PREFS_COOKIE_ATTRS = HttpCookieAttributes( + name="uprefs", + max_age=dt.timedelta(days=30 * 6), # approximately 6 months + http_only=True, + same_site="Lax", +) + +_logger = logging.getLogger(__name__) + + +def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs: + def new_content(): + return UserPrefs() + + cookie_content: str | None = request.COOKIES.get(_USER_PREFS_COOKIE_ATTRS.name) + if cookie_content is None or len(cookie_content) < 5: + return new_content() + + try: + user_prefs = UserPrefs.from_cookie_content(cookie_content) + return user_prefs + except MsgspecError: + _logger.exception( + "Could not decode cookie content; restarting with a blank one." + ) + return new_content() + + +def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None: + set_http_cookie_on_django_response( + response=response, + attributes=_USER_PREFS_COOKIE_ATTRS, + value=user_prefs.to_cookie_content(), + ) diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index 7e7e5f0..bc008a8 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -109,6 +109,7 @@ CACHES = { "default": { "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": "django_cache", } } From d7963244a5602bdc7c02aa361ac2ddce5c9b56f8 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 13 Sep 2024 11:23:33 +0100 Subject: [PATCH 25/42] [lichess] Add a "Lichess account" modal, improve the UI a bit here and there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Still pretty ugly overall... but we're working on it 😅 --- src/apps/chess/types.py | 2 +- .../components/misc_ui/daily_challenge_bar.py | 4 +- .../components/misc_ui/help.py | 10 +-- .../components/misc_ui/help_modal.py | 3 +- .../components/misc_ui/svg_icons.py | 17 ++-- .../components/pages/daily_chess_pages.py | 40 ++------- src/apps/daily_challenge/urls.py | 11 --- src/apps/daily_challenge/views.py | 32 +------- .../components/{misc_ui.py => game_info.py} | 0 .../components/lichess_account.py | 41 ---------- .../components/misc_ui/__init__.py | 0 .../components/misc_ui/user_profile_modal.py | 69 ++++++++++++++++ .../components/ongoing_games.py | 64 ++++++++------- .../components/pages/lichess_pages.py | 81 +++++++++++++------ .../lichess_bridge/components/svg_icons.py | 5 ++ src/apps/lichess_bridge/lichess_api.py | 25 ++++-- src/apps/lichess_bridge/models.py | 61 ++++++-------- src/apps/lichess_bridge/presenters.py | 20 +---- src/apps/lichess_bridge/tests/test_views.py | 4 +- src/apps/lichess_bridge/urls.py | 6 ++ src/apps/lichess_bridge/views.py | 49 +++++++++-- src/apps/webui/components/layout.py | 2 +- src/apps/webui/components/misc_ui/__init__.py | 0 src/apps/webui/components/misc_ui/header.py | 18 +++++ .../webui/components/misc_ui/svg_icons.py | 15 ++++ .../components/misc_ui/user_prefs_modal.py | 20 ++++- src/apps/{daily_challenge => webui}/forms.py | 0 src/apps/webui/urls.py | 14 ++++ src/apps/webui/views.py | 32 ++++++++ src/project/settings/_base.py | 2 +- src/project/urls.py | 1 + 31 files changed, 382 insertions(+), 266 deletions(-) rename src/apps/lichess_bridge/components/{misc_ui.py => game_info.py} (100%) delete mode 100644 src/apps/lichess_bridge/components/lichess_account.py create mode 100644 src/apps/lichess_bridge/components/misc_ui/__init__.py create mode 100644 src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py create mode 100644 src/apps/webui/components/misc_ui/__init__.py create mode 100644 src/apps/webui/components/misc_ui/header.py create mode 100644 src/apps/webui/components/misc_ui/svg_icons.py rename src/apps/{daily_challenge => webui}/components/misc_ui/user_prefs_modal.py (86%) rename src/apps/{daily_challenge => webui}/forms.py (100%) create mode 100644 src/apps/webui/urls.py create mode 100644 src/apps/webui/views.py diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index 902fb5e..ca07440 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -120,7 +120,7 @@ Faction = Literal[ "humans", - "undeads", + "undeads", # mispelled, but it's a bit everywhere in the codebase now 😅 ] diff --git a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py index b3d502e..015c176 100644 --- a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py @@ -10,10 +10,10 @@ from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM from apps.webui.components import common_styles +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG from ...models import PlayerGameOverState from .svg_icons import ( - ICON_SVG_COG, ICON_SVG_LIGHT_BULB, ICON_SVG_RESTART, ICON_SVG_UNDO, @@ -340,7 +340,7 @@ def _see_solution_button( def _user_prefs_button(board_id: str) -> "dom_tag": htmx_attributes = { - "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_user_prefs"), + "data_hx_get": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", "data_hx_swap": "outerHTML", } diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index e69828a..c69bdc5 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -9,12 +9,13 @@ from apps.chess.components.chess_board import SQUARE_COLOR_TAILWIND_CLASSES from apps.chess.components.chess_helpers import chess_unit_symbol_class from apps.chess.consts import PIECE_TYPE_TO_NAME -from apps.daily_challenge.components.misc_ui.svg_icons import ( - ICON_SVG_COG, +from apps.webui.components import common_styles +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG + +from .svg_icons import ( ICON_SVG_LIGHT_BULB, ICON_SVG_RESTART, ) -from apps.webui.components import common_styles if TYPE_CHECKING: from dominate.tags import dom_tag @@ -54,9 +55,6 @@ def help_content( challenge_solution_turns_count: int, factions: "GameFactions", ) -> "dom_tag": - # N.B. We use a tuple here for the factions, so they're hashable - # and can be used as cached key - spacing = "mb-3" return raw( diff --git a/src/apps/daily_challenge/components/misc_ui/help_modal.py b/src/apps/daily_challenge/components/misc_ui/help_modal.py index ef805d3..0b7f3aa 100644 --- a/src/apps/daily_challenge/components/misc_ui/help_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/help_modal.py @@ -4,8 +4,7 @@ from apps.chess.components.misc_ui import modal_container from apps.daily_challenge.components.misc_ui.help import help_content - -from .svg_icons import ICON_SVG_HELP +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_HELP if TYPE_CHECKING: from dominate.tags import dom_tag diff --git a/src/apps/daily_challenge/components/misc_ui/svg_icons.py b/src/apps/daily_challenge/components/misc_ui/svg_icons.py index 5979cd0..e6ecd13 100644 --- a/src/apps/daily_challenge/components/misc_ui/svg_icons.py +++ b/src/apps/daily_challenge/components/misc_ui/svg_icons.py @@ -6,42 +6,35 @@ """ ) + # https://heroicons.com/, icon `chart-bar-square` ICON_SVG_STATS = raw( r""" """ ) -# https://heroicons.com/, icon `question-mark-circle` -ICON_SVG_HELP = raw( - r""" - - """ -) -# https://heroicons.com/, icon `cog-8-tooth`, "solid" version -ICON_SVG_COG = raw( - r""" - - """ -) + # https://heroicons.com/, icon `light-bulb` ICON_SVG_LIGHT_BULB = raw( r""" """ ) + # https://heroicons.com/, icon `play` ICON_SVG_PLAY = raw( r""" """ ) + # https://heroicons.com/, icon `forward` ICON_SVG_FORWARD = raw( r""" """ ) + # https://heroicons.com/, icon `backward` ICON_SVG_UNDO = raw( r""" diff --git a/src/apps/daily_challenge/components/pages/daily_chess_pages.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py index c12ede7..1ea6a3b 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess_pages.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -5,7 +5,7 @@ from django.conf import settings from django.templatetags.static import static from django.urls import reverse -from dominate.tags import button, div, meta, script +from dominate.tags import div, meta, script from dominate.util import raw from apps.chess.components.chess_board import ( @@ -19,10 +19,13 @@ speech_bubble_container, ) from apps.webui.components.layout import page +from apps.webui.components.misc_ui.header import header_button +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_HELP +from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button from ..misc_ui.daily_challenge_bar import daily_challenge_bar from ..misc_ui.status_bar import status_bar -from ..misc_ui.svg_icons import ICON_SVG_COG, ICON_SVG_HELP, ICON_SVG_STATS +from ..misc_ui.svg_icons import ICON_SVG_STATS if TYPE_CHECKING: from typing import Literal @@ -56,7 +59,7 @@ def daily_challenge_page( _open_help_modal() if game_presenter.is_very_first_game else div(""), request=request, left_side_buttons=[_stats_button()], - right_side_buttons=[_user_prefs_button(), _help_button()], + right_side_buttons=[user_prefs_button(), _help_button()], head_children=_open_graph_meta_tags(), ) @@ -125,7 +128,7 @@ def _stats_button() -> "dom_tag": "data_hx_swap": "outerHTML", } - return _header_button( + return header_button( icon=ICON_SVG_STATS, title="Visualise your stats for daily challenges", id_="stats-button", @@ -133,21 +136,6 @@ def _stats_button() -> "dom_tag": ) -def _user_prefs_button() -> "dom_tag": - htmx_attributes = { - "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_user_prefs"), - "data_hx_target": "#modals-container", - "data_hx_swap": "outerHTML", - } - - return _header_button( - icon=ICON_SVG_COG, - title="Edit preferences", - id_="user-prefs-button", - htmx_attributes=htmx_attributes, - ) - - def _help_button() -> "dom_tag": htmx_attributes = { "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_help"), @@ -155,7 +143,7 @@ def _help_button() -> "dom_tag": "data_hx_swap": "outerHTML", } - return _header_button( + return header_button( icon=ICON_SVG_HELP, title="How to play", id_="help-button", @@ -163,18 +151,6 @@ def _help_button() -> "dom_tag": ) -def _header_button( - *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] -) -> "dom_tag": - return button( - icon, - cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", - title=title, - id=id_, - **htmx_attributes, - ) - - @functools.cache def _open_stats_modal() -> "dom_tag": # We open the stats modal 2 seconds after the game is won. diff --git a/src/apps/daily_challenge/urls.py b/src/apps/daily_challenge/urls.py index 434c459..0778291 100644 --- a/src/apps/daily_challenge/urls.py +++ b/src/apps/daily_challenge/urls.py @@ -32,11 +32,6 @@ views.htmx_daily_challenge_stats_modal, name="htmx_daily_challenge_modal_stats", ), - path( - "htmx/daily-challenge/modals/user-prefs/", - views.htmx_daily_challenge_user_prefs_modal, - name="htmx_daily_challenge_modal_user_prefs", - ), path( "htmx/daily-challenge/modals/help/", views.htmx_daily_challenge_help_modal, @@ -64,12 +59,6 @@ views.htmx_undo_last_move_do, name="htmx_undo_last_move_do", ), - # User prefs views - path( - "htmx/daily-challenge/user-prefs/", - views.htmx_daily_challenge_user_prefs_save, - name="htmx_daily_challenge_user_prefs_save", - ), # "See the solution" views path( "htmx/daily-challenge/see-solution/ask-confirmation/", diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index 980e62b..6123df4 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -4,15 +4,13 @@ from django.contrib.auth.decorators import user_passes_test from django.http import HttpResponse -from django.shortcuts import redirect, resolve_url +from django.shortcuts import redirect from django.views.decorators.http import require_POST, require_safe -from django_htmx.http import HttpResponseClientRedirect from apps.chess.chess_helpers import get_active_player_side_from_fen, uci_move_squares from apps.chess.types import ChessInvalidActionException, ChessInvalidMoveException from apps.utils.view_decorators import user_is_staff from apps.utils.views_helpers import htmx_aware_redirect -from apps.webui.cookie_helpers import save_user_prefs from .business_logic import ( manage_daily_challenge_defeat_logic, @@ -25,7 +23,6 @@ ) from .components.misc_ui.help_modal import help_modal from .components.misc_ui.stats_modal import stats_modal -from .components.misc_ui.user_prefs_modal import user_prefs_modal from .components.pages.daily_chess_pages import ( daily_challenge_moving_parts_fragment, daily_challenge_page, @@ -35,7 +32,6 @@ get_or_create_daily_challenge_state_for_player, save_daily_challenge_state_in_session, ) -from .forms import UserPrefsForm from .models import PlayerGameOverState from .presenters import DailyChallengeGamePresenter from .view_helpers import get_current_daily_challenge_or_admin_preview @@ -254,16 +250,6 @@ def htmx_daily_challenge_help_modal( return HttpResponse(str(modal_content)) -@require_safe -@with_game_context -def htmx_daily_challenge_user_prefs_modal( - request: "HttpRequest", *, ctx: "GameContext" -) -> HttpResponse: - modal_content = user_prefs_modal(user_prefs=ctx.user_prefs) - - return HttpResponse(str(modal_content)) - - @require_POST @with_game_context @redirect_if_game_not_started @@ -378,22 +364,6 @@ def htmx_undo_last_move_do( ) -@require_POST -def htmx_daily_challenge_user_prefs_save(request: "HttpRequest") -> HttpResponse: - # As user preferences updates can have an impact on any part of the UI - # (changing the way the chess board is displayed, for example), we'd better - # reload the whole page after having saved preferences. - response = HttpResponseClientRedirect( - resolve_url("daily_challenge:daily_game_view") - ) - - form = UserPrefsForm(request.POST) - if user_prefs := form.to_user_prefs(): - save_user_prefs(user_prefs=user_prefs, response=response) - - return response - - @require_POST @with_game_context @redirect_if_game_not_started diff --git a/src/apps/lichess_bridge/components/misc_ui.py b/src/apps/lichess_bridge/components/game_info.py similarity index 100% rename from src/apps/lichess_bridge/components/misc_ui.py rename to src/apps/lichess_bridge/components/game_info.py diff --git a/src/apps/lichess_bridge/components/lichess_account.py b/src/apps/lichess_bridge/components/lichess_account.py deleted file mode 100644 index 7856ed1..0000000 --- a/src/apps/lichess_bridge/components/lichess_account.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import TYPE_CHECKING - -from django.urls import reverse -from dominate.tags import button, div, form, p - -from apps.webui.components.forms_common import csrf_hidden_input - -if TYPE_CHECKING: - from django.http import HttpRequest - from dominate.tags import html_tag - - -def lichess_linked_account_inner_footer(request: "HttpRequest") -> "html_tag": - return div( - detach_lichess_account_form(request), - cls="my-4", - ) - - -def detach_lichess_account_form(request: "HttpRequest") -> form: - return form( - csrf_hidden_input(request), - p( - "You can disconnect your Lichess account from Zakuchess at any time.", - cls="text-center", - ), - p( - "Tap ", - button( - "here", - type="submit", - cls="text-rose-600 underline", - ), - " to disconnect it.", - cls="text-center", - ), - p("(you can always reconnect it later)", cls="text-center"), - action=reverse("lichess_bridge:detach_lichess_account"), - method="POST", - cls="mt-16 text-slate-50 text-sm", - ) diff --git a/src/apps/lichess_bridge/components/misc_ui/__init__.py b/src/apps/lichess_bridge/components/misc_ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py new file mode 100644 index 0000000..5ddbbe6 --- /dev/null +++ b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py @@ -0,0 +1,69 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import button, div, form, h3, h4, p, span + +from apps.chess.components.misc_ui import modal_container +from apps.webui.components import common_styles +from apps.webui.components.forms_common import csrf_hidden_input + +from ..svg_icons import ICON_SVG_LOG_OUT, ICON_SVG_USER + +if TYPE_CHECKING: + from django.http import HttpRequest + from dominate.tags import dom_tag + + from apps.lichess_bridge.models import LichessAccountInformation + + +def user_profile_modal( + *, request: "HttpRequest", me: "LichessAccountInformation" +) -> "dom_tag": + return modal_container( + header=h3( + "Lichess account ", + ICON_SVG_USER, + cls="text-xl", + ), + body=div( + _user_profile_form(request, me), + cls="p-6 space-y-6", + ), + ) + + +def _user_profile_form(request: "HttpRequest", me: "LichessAccountInformation") -> form: + spacing = "mb-3" + + return form( + csrf_hidden_input(request), + h4( + "Your Lichess account '", + span(me.username, cls="text-yellow-400"), + "' is connected to ZakuChess.", + cls=f"{spacing} text-center font-bold ", + ), + p( + "ZakuChess doesn't store anything related to your Lichess account: " + "this connection only exists in your web browser.", + cls=f"{spacing} text-center", + ), + p( + "If you want to disconnect your Lichess account from Zakuchess, ", + "Use the following button:", + cls=f"{spacing} text-center", + ), + p( + button( + "Disconnect Lichess account", + " ", + ICON_SVG_LOG_OUT, + type="submit", + cls=common_styles.BUTTON_CANCEL_CLASSES, + ), + cls=f"{spacing} text-center", + ), + p("You can always reconnect it later 🙂", cls=f"{spacing} text-center"), + action=reverse("lichess_bridge:detach_lichess_account"), + method="POST", + ) diff --git a/src/apps/lichess_bridge/components/ongoing_games.py b/src/apps/lichess_bridge/components/ongoing_games.py index 7e46067..d88154d 100644 --- a/src/apps/lichess_bridge/components/ongoing_games.py +++ b/src/apps/lichess_bridge/components/ongoing_games.py @@ -1,10 +1,11 @@ from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import a, caption, div, table, tbody, td, th, thead, tr +from dominate.tags import a, b, caption, div, table, tbody, td, th, thead, tr -from ...chess.chess_helpers import get_turns_counter_from_fen -from .misc_ui import time_left_display +from apps.chess.chess_helpers import get_turns_counter_from_fen + +from .game_info import time_left_display if TYPE_CHECKING: from dominate.tags import html_tag @@ -13,32 +14,36 @@ def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "html_tag": - th_classes = "border border-slate-300 dark:border-slate-600 font-semibold p-2 text-slate-900 dark:text-slate-200" - - return table( - caption("Your ongoing games", cls="mb-2"), - thead( - tr( - th("Opponent", cls=th_classes), - th("Moves", cls=th_classes), - th("Time", cls=th_classes), - th("Turn", cls=th_classes), + th_classes = "p-2" + + return div( + table( + caption("Correspondence games", cls="mb-2 text-slate-50 "), + thead( + tr( + th("Opponent", cls=th_classes), + th("Moves", cls=th_classes), + th("Time", cls=th_classes), + th("Turn", cls=th_classes), + cls="bg-rose-900 text-slate-200 font-bold", + ), ), - ), - tbody( - *[_ongoing_game_row(game) for game in ongoing_games] - if ongoing_games - else tr( - td( - div( - "You have no ongoing games on Lichess at the moment", - cls="italic p-8 text-center", - ), - colspan=4, - ) + tbody( + *[_ongoing_game_row(game) for game in ongoing_games] + if ongoing_games + else tr( + td( + div( + "You have no ongoing games on Lichess at the moment", + cls="italic p-8 text-center", + ), + colspan=4, + ) + ), ), + cls="w-full border-separate border-spacing-0 border border-slate-500 rounded-md", ), - cls="w-full my-4 border-separate border-spacing-2 border border-slate-500 rounded-md", + cls="my-4 px-1 ", ) @@ -52,10 +57,11 @@ def _ongoing_game_row(game: "LichessOngoingGameData") -> tr: "lichess_bridge:correspondence_game", kwargs={"game_id": game.gameId}, ), + cls="font-bold underline", ), cls=td_classes, ), - td(get_turns_counter_from_fen(game.fen), cls=td_classes), - td(time_left_display(game.secondsLeft), cls=td_classes), - td("Mine" if game.isMyTurn else "Theirs", cls=td_classes), + td(get_turns_counter_from_fen(game.fen), cls=f"{td_classes} text-right"), + td(time_left_display(game.secondsLeft), cls=f"{td_classes} text-right"), + td(b("Mine") if game.isMyTurn else "Theirs", cls=f"{td_classes} text-right"), ) diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 83d173d..ef50783 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -1,10 +1,9 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from django.conf import settings from django.urls import reverse from dominate.tags import ( a, - b, button, div, fieldset, @@ -15,6 +14,7 @@ legend, p, section, + span, ) from apps.chess.components.chess_board import ( @@ -27,17 +27,16 @@ from apps.webui.components import common_styles from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page +from apps.webui.components.misc_ui.header import header_button +from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button from ...models import LichessCorrespondenceGameDaysChoice -from ..lichess_account import ( - detach_lichess_account_form, - lichess_linked_account_inner_footer, -) from ..ongoing_games import lichess_ongoing_games -from ..svg_icons import ICON_SVG_CREATE, ICON_SVG_LOG_IN +from ..svg_icons import ICON_SVG_CREATE, ICON_SVG_LOG_IN, ICON_SVG_USER if TYPE_CHECKING: from django.http import HttpRequest + from dominate.tags import dom_tag from ...models import ( LichessAccountInformation, @@ -69,6 +68,7 @@ def lichess_no_account_linked_page( ), request=request, title="Lichess - no account linked", + **_get_page_header_buttons(lichess_profile_linked=False), ) @@ -82,9 +82,8 @@ def lichess_my_current_games_list_page( div( section( h3( - "Logged in as ", - b(f"{me.username}@Lichess", cls="text-yellow-400 font-bold"), - cls="mb-2 border rounded-t-md border-slate-700 bg-slate-800 text-lg text-center", + "Your ongoing games on Lichess", + cls="text-slate-50 font-bold text-center", ), lichess_ongoing_games(ongoing_games), p( @@ -93,20 +92,20 @@ def lichess_my_current_games_list_page( href=reverse("lichess_bridge:create_game"), cls=common_styles.BUTTON_CLASSES, ), + cls="my-8 text-center text-slate-50", ), - cls="text-center text-slate-50", ), - lichess_linked_account_inner_footer(request), - cls="w-full mx-auto bg-slate-900 min-h-48 pb-4 " - " md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", + _lichess_account_footer(me), + cls="w-full mx-auto bg-slate-900 min-h-48 pb-4 md:max-w-3xl", ), request=request, title="Lichess - account linked", + **_get_page_header_buttons(lichess_profile_linked=True), ) def lichess_correspondence_game_creation_page( - request: "HttpRequest", form_errors: dict + request: "HttpRequest", *, me: "LichessAccountInformation", form_errors: dict ) -> str: return page( div( @@ -144,11 +143,6 @@ def lichess_correspondence_game_creation_page( ), cls="block text-sm font-bold mb-2", ), - input_( - id="days-per-turn", - cls="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline", - ), - cls="mb-4", ), button( "Create", @@ -160,23 +154,21 @@ def lichess_correspondence_game_creation_page( action=reverse("lichess_bridge:create_game"), method="POST", ), + _lichess_account_footer(me), cls="text-slate-50", ), - div( - detach_lichess_account_form(request), - cls="mt-4", - ), - cls="w-full mx-auto bg-slate-900 min-h-48 " - "md:max-w-3xl xl:max-w-7xl xl:border xl:rounded-md xl:border-neutral-800", + cls="w-full mx-auto bg-slate-900 min-h-48 md:max-w-3xl", ), request=request, title="Lichess - new correspondence game", + **_get_page_header_buttons(lichess_profile_linked=True), ) def lichess_correspondence_game_page( *, request: "HttpRequest", + me: "LichessAccountInformation", game_presenter: "LichessCorrespondenceGamePresenter", ) -> str: return page( @@ -185,8 +177,10 @@ def lichess_correspondence_game_page( status_bars=[], board_id="main", ), + _lichess_account_footer(me), request=request, title=f"Lichess - correspondence game {game_presenter.game_id}", + **_get_page_header_buttons(lichess_profile_linked=True), ) @@ -229,3 +223,38 @@ def lichess_game_moving_parts_fragment( ) ) ) + + +def _lichess_account_footer(me: "LichessAccountInformation") -> "dom_tag": + return p( + "Your Lichess account: ", + span(me.username, cls="text-yellow-400"), + cls="mt-8 mb-4 text-slate-50 text-center text-sm", + ) + + +class _PageHeaderButtons(TypedDict): + left_side_buttons: list["dom_tag"] + right_side_buttons: list["dom_tag"] + + +def _get_page_header_buttons(lichess_profile_linked: bool) -> _PageHeaderButtons: + return _PageHeaderButtons( + left_side_buttons=[_user_account_button()] if lichess_profile_linked else [], + right_side_buttons=[user_prefs_button()], + ) + + +def _user_account_button() -> "dom_tag": + htmx_attributes = { + "data_hx_get": reverse("lichess_bridge:htmx_modal_user_account"), + "data_hx_target": "#modals-container", + "data_hx_swap": "outerHTML", + } + + return header_button( + icon=ICON_SVG_USER, + title="Manage your Lichess account", + id_="stats-button", + htmx_attributes=htmx_attributes, + ) diff --git a/src/apps/lichess_bridge/components/svg_icons.py b/src/apps/lichess_bridge/components/svg_icons.py index 756142e..8875405 100644 --- a/src/apps/lichess_bridge/components/svg_icons.py +++ b/src/apps/lichess_bridge/components/svg_icons.py @@ -20,3 +20,8 @@ """ ) +ICON_SVG_USER = raw( + r""" + + """ +) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index 447e57a..e65b7a8 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -1,3 +1,4 @@ +import asyncio import contextlib import datetime as dt import logging @@ -30,7 +31,7 @@ _GET_MY_ACCOUNT_CACHE = { "KEY_PATTERN": "lichess_bridge::get_my_account::{lichess_access_token_hash}", - "DURATION": dt.timedelta(seconds=120).total_seconds(), + "DURATION": dt.timedelta(minutes=30).total_seconds(), } _GET_GAME_BY_ID_FROM_STREAM_CACHE = { @@ -181,10 +182,21 @@ async def get_game_by_id_from_stream( async def clear_game_by_id_cache(game_id: "LichessGameId") -> None: - get_game_by_id_cache_key = _GET_EXPORT_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + """ + Clear the cached data of `get_game_export_by_id` and `get_game_by_id_from_stream` for + a given game ID. + """ + get_game_export_by_id_cache_key = _GET_EXPORT_BY_ID_CACHE["KEY_PATTERN"].format( # type: ignore[attr-defined] + game_id=game_id, + ) + get_game_by_id_from_stream_cache_key = _GET_GAME_BY_ID_FROM_STREAM_CACHE[ + "KEY_PATTERN" + ].format( # type: ignore[attr-defined] game_id=game_id, ) - await cache.adelete(get_game_by_id_cache_key) + async with asyncio.TaskGroup() as tg: + tg.create_task(cache.adelete(get_game_export_by_id_cache_key)) + tg.create_task(cache.adelete(get_game_by_id_from_stream_cache_key)) async def move_lichess_game_piece( @@ -197,7 +209,8 @@ async def move_lichess_game_piece( ) -> bool: """ Calling this function will make a move in a Lichess game. - As a side effect, it will also clear the `get_game_by_id` cache for that game. + As a side effect, it will also clear the cached data of `get_game_export_by_id` + and `get_game_by_id_from_stream` for that game. """ # https://lichess.org/api#tag/Board/operation/boardGameMove move_uci = f"{from_}{to}" @@ -239,11 +252,11 @@ def get_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncCli @contextlib.contextmanager -def _lichess_api_monitoring(target_endpoint, method) -> "Iterator[None]": +def _lichess_api_monitoring(method, target_endpoint) -> "Iterator[None]": start_time = time.monotonic() yield _logger.info( - "Lichess API: %s '%s' took %ims.", + "Lichess API: %s '%s' took %i ms.", method, target_endpoint, (time.monotonic() - start_time) * 1000, diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 15e81eb..97ad53d 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -20,6 +20,7 @@ from apps.chess.models import GameTeams from apps.chess.types import ( + BoardOrientation, Faction, PieceRoleBySquare, PlayerSide, @@ -43,6 +44,7 @@ # TODO: remove this, as it may break one day? LICHESS_ACCESS_TOKEN_PREFIX = "lio_" + # The values of these enums can be found in the OpenAPI spec one can download # by clicking the "Download" button at the top of this page: # https://lichess.org/api @@ -99,6 +101,12 @@ "variantEnd", ] +# For now we hard-code the fact that "me" always plays the "humans" faction, +# and "them" always plays the "undeads" faction. +_FACTIONS_BY_BOARD_ORIENTATION: dict["BoardOrientation", GameFactions] = { + "1->8": GameFactions(w="humans", b="undeads"), + "8->1": GameFactions(w="undeads", b="humans"), +} # Presenters are the objects we pass to our templates. _LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict["LichessPlayerSide", "PlayerSide"] = { @@ -245,6 +253,10 @@ class LichessGameFullFromStream(msgspec.Struct): type: Literal["gameFull"] state: LichessGameState + @property + def is_ongoing_game(self) -> bool: + return self.state.status == "started" + class LichessGameWithMetadataBase(ABC): @property @@ -271,24 +283,13 @@ def active_player_side(self) -> "LichessPlayerSide": @abstractmethod def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": ... + @functools.cached_property + def board_orientation(self) -> "BoardOrientation": + return self._players_sides.board_orientation + @functools.cached_property def game_factions(self) -> GameFactions: - my_side = self._players_sides[0] - # For now we hard-code the fact that "me" always plays the "humans" faction, - # and "them" always plays the "undeads" faction. - factions: "tuple[Faction, Faction]" = ( - "humans", - "undeads", - ) - if my_side == "white": - w_faction, b_faction = factions - else: - b_faction, w_faction = factions - - return GameFactions( - w=w_faction, - b=b_faction, - ) + return _FACTIONS_BY_BOARD_ORIENTATION[self.board_orientation] @property @abstractmethod @@ -327,7 +328,7 @@ def active_player_side(self) -> "LichessPlayerSide": @functools.cached_property def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": - my_side, their_side = self._players_sides + my_side, their_side, _ = self._players_sides my_player: "LichessGameEventPlayer" = getattr(self.raw_data, my_side) their_player: "LichessGameEventPlayer" = getattr(self.raw_data, their_side) @@ -356,10 +357,12 @@ def _players_sides(self) -> "LichessGameMetadataPlayerSides": "white" if self.raw_data.white.id == self.my_player_id else "black" ) their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + board_orientation: "BoardOrientation" = "1->8" if my_side == "white" else "8->1" return LichessGameMetadataPlayerSides( me=my_side, them=their_side, + board_orientation=board_orientation, ) @functools.cached_property @@ -409,7 +412,7 @@ def active_player_side(self) -> "LichessPlayerSide": @functools.cached_property def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": - my_side, their_side = self._players_sides + my_side, their_side, _ = self._players_sides my_player: "LichessGameUser" = getattr(self.raw_data.players, my_side).user their_player: "LichessGameUser" = getattr( @@ -434,25 +437,6 @@ def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": return result - @functools.cached_property - def game_factions(self) -> GameFactions: - my_side = self._players_sides[0] - # For now we hard-code the fact that "me" always plays the "humans" faction, - # and "them" always plays the "undeads" faction. - factions: "tuple[Faction, Faction]" = ( - "humans", - "undeads", - ) - if my_side == "white": - w_faction, b_faction = factions - else: - b_faction, w_faction = factions - - return GameFactions( - w=w_faction, - b=b_faction, - ) - @functools.cached_property def _players_sides(self) -> "LichessGameMetadataPlayerSides": my_side: "LichessPlayerSide" = ( @@ -461,10 +445,12 @@ def _players_sides(self) -> "LichessGameMetadataPlayerSides": else "black" ) their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" + board_orientation: "BoardOrientation" = "1->8" if my_side == "white" else "8->1" return LichessGameMetadataPlayerSides( me=my_side, them=their_side, + board_orientation=board_orientation, ) @functools.cached_property @@ -477,6 +463,7 @@ def _rebuilt_game(self) -> "RebuildGameFromPgnResult": class LichessGameMetadataPlayerSides(NamedTuple): me: "LichessPlayerSide" them: "LichessPlayerSide" + board_orientation: "BoardOrientation" class LichessGameMetadataPlayer(NamedTuple): diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 880542f..1df9270 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -5,16 +5,14 @@ from django.urls import reverse from apps.chess.chess_helpers import uci_move_squares -from apps.chess.models import GameFactions from apps.chess.presenters import GamePresenter, GamePresenterUrls if TYPE_CHECKING: - from apps.chess.models import UserPrefs + from apps.chess.models import GameFactions, UserPrefs from apps.chess.presenters import SpeechBubbleData from apps.chess.types import ( FEN, BoardOrientation, - Faction, GamePhase, PlayerSide, Square, @@ -56,11 +54,7 @@ def __init__( @cached_property def board_orientation(self) -> "BoardOrientation": - return ( - "1->8" - if self._game_data.players_from_my_perspective.me.player_side == "w" - else "8->1" - ) + return self._game_data.board_orientation @cached_property def urls(self) -> "GamePresenterUrls": @@ -98,14 +92,8 @@ def game_id(self) -> str: return self._game_data.raw_data.id @cached_property - def factions(self) -> GameFactions: - players = self._game_data.players_from_my_perspective - w_faction: "Faction" = "humans" if players.me.player_side == "w" else "undeads" - b_faction: "Faction" = "undeads" if w_faction == "humans" else "humans" - return GameFactions( - w=w_faction, - b=b_faction, - ) + def factions(self) -> "GameFactions": + return self._game_data.game_factions @property def is_intro_turn(self) -> bool: diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index d1027d5..522570c 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -35,7 +35,7 @@ def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): response_html = response.content.decode("utf-8") assert "Log in via Lichess" in response_html - assert "Log out from Lichess" not in response_html + assert "Manage your Lichess account" not in response_html @pytest.mark.django_db # just because we use the DatabaseCache @@ -87,7 +87,7 @@ async def get(self, path, **kwargs): response_html = response.content.decode("utf-8") assert "Log in via Lichess" not in response_html - assert "disconnect your Lichess account" in response_html + assert "Manage your Lichess account" in response_html assert "ChessChampion" in response_html diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index d7f1225..aac9fe3 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -33,6 +33,12 @@ views.htmx_game_move_piece, name="htmx_game_move_piece", ), + # Modals: + path( + "htmx/modals/user-account/", + views.htmx_user_account_modal, + name="htmx_modal_user_account", + ), # OAuth2 Views: path( "oauth2/start-flow/", diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 133c449..0948989 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -16,6 +16,7 @@ fetch_lichess_token_from_oauth2_callback, get_lichess_token_retrieval_via_oauth2_process_starting_url, ) +from .components.misc_ui.user_profile_modal import user_profile_modal from .components.pages import lichess_pages as lichess_pages from .components.pages.lichess_pages import lichess_game_moving_parts_fragment from .forms import LichessCorrespondenceGameCreationForm @@ -32,7 +33,11 @@ from django.http import HttpRequest from apps.chess.models import UserPrefs - from apps.chess.types import ChessInvalidMoveException, Square + from apps.chess.types import ( + ChessInvalidActionException, + ChessInvalidMoveException, + Square, + ) from .models import ( LichessAccessToken, @@ -81,6 +86,8 @@ async def lichess_my_games_list_page( async def lichess_game_create( request: "HttpRequest", *, lichess_access_token: "LichessAccessToken" ) -> HttpResponse: + me = await _get_me_from_lichess(lichess_access_token) + form_errors = {} if request.method == "POST": form = LichessCorrespondenceGameCreationForm(request.POST) @@ -101,7 +108,7 @@ async def lichess_game_create( return HttpResponse( lichess_pages.lichess_correspondence_game_creation_page( - request=request, form_errors=form_errors + request=request, me=me, form_errors=form_errors ) ) @@ -130,6 +137,7 @@ async def lichess_correspondence_game_page( return HttpResponse( lichess_pages.lichess_correspondence_game_page( request=request, + me=me, game_presenter=game_presenter, ) ) @@ -203,12 +211,11 @@ async def htmx_game_move_piece( if from_ == to: raise ChessInvalidMoveException("Not a move") - # game_over_already = ctx.game_state.game_over != PlayerGameOverState.PLAYING - # - # if ctx.game_state.game_over != PlayerGameOverState.PLAYING: - # raise ChessInvalidActionException("Game is over, cannot move pieces") - me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + + if not game_data.raw_data.is_ongoing_game: + raise ChessInvalidActionException("Game is over, cannot move pieces") + is_my_turn = game_data.players_from_my_perspective.active_player == "me" if not is_my_turn: raise ChessInvalidMoveException("Not my turn") @@ -226,7 +233,9 @@ async def htmx_game_move_piece( # The move was successful, let's re-fetch the updated game state: # (the cache for this game's data has be cleared by `move_lichess_game_piece`) game_data_raw = await lichess_api.get_game_by_id_from_stream( - api_client=lichess_api_client, game_id=game_id + api_client=lichess_api_client, + game_id=game_id, + try_fetching_from_cache=False, ) # TODO: handle end of game after move! @@ -246,6 +255,21 @@ async def htmx_game_move_piece( ) +@require_safe +@with_lichess_access_token +@redirect_if_no_lichess_access_token +async def htmx_user_account_modal( + request: "HttpRequest", + *, + lichess_access_token: "LichessAccessToken", +) -> HttpResponse: + me = await _get_me_from_lichess(lichess_access_token) + + modal_content = user_profile_modal(request=request, me=me) + + return HttpResponse(str(modal_content)) + + @require_POST def lichess_redirect_to_oauth2_flow_starting_url( request: "HttpRequest", @@ -352,6 +376,15 @@ async def _get_my_games_list_page_content( ) +async def _get_me_from_lichess( + lichess_access_token: "LichessAccessToken", +) -> "LichessAccountInformation": + async with lichess_api.get_lichess_api_client( + access_token=lichess_access_token + ) as lichess_api_client: + return await lichess_api.get_my_account(api_client=lichess_api_client) + + async def _get_game_context_from_lichess( lichess_access_token: "LichessAccessToken", game_id: "LichessGameId", diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index 99c9018..a9f8d17 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -128,7 +128,7 @@ def head(*children: "dom_tag", title: str) -> "dom_tag": link( # automatically created by `django-google-fonts` rel="stylesheet", - href=static("fonts/opensans.css"), + href=static("fonts/opensans::ital,wght@0,300..800;1,300..800.css"), ), # CSS & JS link(rel="stylesheet", href=static("webui/css/zakuchess.css")), diff --git a/src/apps/webui/components/misc_ui/__init__.py b/src/apps/webui/components/misc_ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/webui/components/misc_ui/header.py b/src/apps/webui/components/misc_ui/header.py new file mode 100644 index 0000000..e6fdf98 --- /dev/null +++ b/src/apps/webui/components/misc_ui/header.py @@ -0,0 +1,18 @@ +from typing import TYPE_CHECKING + +from dominate.tags import button + +if TYPE_CHECKING: + from dominate.tags import dom_tag + + +def header_button( + *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] +) -> "dom_tag": + return button( + icon, + cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", + title=title, + id=id_, + **htmx_attributes, + ) diff --git a/src/apps/webui/components/misc_ui/svg_icons.py b/src/apps/webui/components/misc_ui/svg_icons.py new file mode 100644 index 0000000..0324af5 --- /dev/null +++ b/src/apps/webui/components/misc_ui/svg_icons.py @@ -0,0 +1,15 @@ +from dominate.util import raw + +# https://heroicons.com/, icon `question-mark-circle` +ICON_SVG_HELP = raw( + r""" + + """ +) + +# https://heroicons.com/, icon `cog-8-tooth`, "solid" version +ICON_SVG_COG = raw( + r""" + + """ +) diff --git a/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py b/src/apps/webui/components/misc_ui/user_prefs_modal.py similarity index 86% rename from src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py rename to src/apps/webui/components/misc_ui/user_prefs_modal.py index 1c8ef3f..793bf36 100644 --- a/src/apps/daily_challenge/components/misc_ui/user_prefs_modal.py +++ b/src/apps/webui/components/misc_ui/user_prefs_modal.py @@ -6,8 +6,9 @@ from apps.chess.components.misc_ui import modal_container from apps.chess.components.svg_icons import ICON_SVG_CONFIRM from apps.chess.models import UserPrefsBoardTextureChoices, UserPrefsGameSpeedChoices -from apps.webui.components import common_styles +from .. import common_styles +from .header import header_button from .svg_icons import ICON_SVG_COG if TYPE_CHECKING: @@ -22,6 +23,21 @@ # TODO: manage i18n +def user_prefs_button() -> "dom_tag": + htmx_attributes = { + "data_hx_get": reverse("webui:htmx_modal_user_prefs"), + "data_hx_target": "#modals-container", + "data_hx_swap": "outerHTML", + } + + return header_button( + icon=ICON_SVG_COG, + title="Edit preferences", + id_="user-prefs-button", + htmx_attributes=htmx_attributes, + ) + + def user_prefs_modal(*, user_prefs: "UserPrefs") -> "dom_tag": return modal_container( header=h3( @@ -39,7 +55,7 @@ def user_prefs_modal(*, user_prefs: "UserPrefs") -> "dom_tag": def _user_prefs_form(user_prefs: "UserPrefs") -> "dom_tag": form_htmx_attributes = { - "data_hx_post": reverse("daily_challenge:htmx_daily_challenge_user_prefs_save"), + "data_hx_post": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", "data_hx_swap": "innerHTML", } diff --git a/src/apps/daily_challenge/forms.py b/src/apps/webui/forms.py similarity index 100% rename from src/apps/daily_challenge/forms.py rename to src/apps/webui/forms.py diff --git a/src/apps/webui/urls.py b/src/apps/webui/urls.py new file mode 100644 index 0000000..7e368f1 --- /dev/null +++ b/src/apps/webui/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +app_name = "webui" + +urlpatterns = [ + # User prefs + path( + "htmx/modals/user-prefs/", + views.htmx_user_prefs_modal, + name="htmx_modal_user_prefs", + ), +] diff --git a/src/apps/webui/views.py b/src/apps/webui/views.py new file mode 100644 index 0000000..9ab4f49 --- /dev/null +++ b/src/apps/webui/views.py @@ -0,0 +1,32 @@ +from typing import TYPE_CHECKING + +from django.http import HttpResponse +from django.views.decorators.http import require_http_methods +from django_htmx.http import HttpResponseClientRedirect + +from .components.misc_ui.user_prefs_modal import user_prefs_modal +from .cookie_helpers import get_user_prefs_from_request, save_user_prefs +from .forms import UserPrefsForm + +if TYPE_CHECKING: + from django.http import HttpRequest + + +@require_http_methods(["HEAD", "GET", "POST"]) +def htmx_user_prefs_modal(request: "HttpRequest") -> HttpResponse: + if request.method == "POST": + # As user preferences updates can have an impact on any part of the UI + # (changing the way the chess board is displayed, for example), we'd better + # reload the whole page after having saved preferences. + response = HttpResponseClientRedirect("/") + + form = UserPrefsForm(request.POST) + if user_prefs := form.to_user_prefs(): + save_user_prefs(user_prefs=user_prefs, response=response) + + return response + + user_prefs = get_user_prefs_from_request(request) + modal_content = user_prefs_modal(user_prefs=user_prefs) + + return HttpResponse(str(modal_content)) diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index bc008a8..ce966b9 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -195,7 +195,7 @@ # Google fonts to mirror locally: # https://github.com/andymckay/django-google-fonts GOOGLE_FONTS = ( - "Open Sans", # https://fonts.google.com/specimen/Open+Sans + "Open Sans::ital,wght@0,300..800;1,300..800", # https://fonts.google.com/specimen/Open+Sans ) GOOGLE_FONTS_DIR = BASE_DIR / "src" / "apps" / "webui" / "static" diff --git a/src/project/urls.py b/src/project/urls.py index 7dc8ce7..fc867cf 100644 --- a/src/project/urls.py +++ b/src/project/urls.py @@ -12,6 +12,7 @@ register_converter(ChessSquareConverter, "square") urlpatterns = [ + path("", include("apps.webui.urls")), path("", include("apps.daily_challenge.urls")), path("lichess/", include("apps.lichess_bridge.urls")), path("-/", include("django_alive.urls")), From fb6c6876fa4611704de8236808e9af6592f3461b Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 13 Sep 2024 16:09:09 +0100 Subject: [PATCH 26/42] [lichess] Making the UI a bit less ugly, bit by bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design is hard - especially when you have no natural talent nor acquired skills for it 😅 --- .../components/misc_ui/help.py | 118 +---------- .../components/misc_ui/status_bar.py | 6 +- .../components/game_creation.py | 62 ++++++ .../components/ongoing_games.py | 43 +++- .../components/pages/lichess_pages.py | 188 +++++++++++------- src/apps/lichess_bridge/urls.py | 2 +- src/apps/lichess_bridge/views.py | 2 +- src/apps/webui/components/chess_units.py | 120 +++++++++++ src/apps/webui/components/layout.py | 2 +- src/project/settings/_base.py | 2 +- 10 files changed, 350 insertions(+), 195 deletions(-) create mode 100644 src/apps/lichess_bridge/components/game_creation.py create mode 100644 src/apps/webui/components/chess_units.py diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index c69bdc5..eb28983 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -1,15 +1,16 @@ -import random from functools import cache -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from django.conf import settings from dominate.tags import div, h4, p, span from dominate.util import raw -from apps.chess.components.chess_board import SQUARE_COLOR_TAILWIND_CLASSES -from apps.chess.components.chess_helpers import chess_unit_symbol_class -from apps.chess.consts import PIECE_TYPE_TO_NAME from apps.webui.components import common_styles +from apps.webui.components.chess_units import ( + CHARACTER_TYPE_TIP, + chess_status_bar_tip, + unit_display_container, +) from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG from .svg_icons import ( @@ -21,32 +22,6 @@ from dominate.tags import dom_tag from apps.chess.models import GameFactions - from apps.chess.types import ( - PieceName, - PieceRole, - PieceType, - PlayerSide, - TeamMemberRole, - ) - -_CHARACTER_TYPE_TIP: dict["PieceType", str] = { - "p": "Characters with swords", - "n": "Mounted characters", - "b": "Characters with a bow", - "r": "Flying characters", - "q": "Characters with a staff", - "k": "Characters wearing heavy armors", -} -_CHARACTER_TYPE_TIP_KEYS = tuple(_CHARACTER_TYPE_TIP.keys()) - -_CHARACTER_TYPE_ROLE_MAPPING: dict["PieceType", "TeamMemberRole"] = { - "p": "p1", - "n": "n1", - "b": "b1", - "r": "r1", - "q": "q", - "k": "k", -} @cache @@ -130,89 +105,10 @@ def help_content( additional_classes="h-20", row_counter=i, ) - for i, piece_type in enumerate(_CHARACTER_TYPE_TIP_KEYS) + for i, piece_type in enumerate(CHARACTER_TYPE_TIP.keys()) ), cls="mt-2", ), cls="w-full text-center", ).render(pretty=settings.DEBUG) ) - - -def chess_status_bar_tip( - *, - factions: "GameFactions", - piece_type: "PieceType | None" = None, - additional_classes: str = "", - row_counter: int | None = None, -) -> "dom_tag": - if piece_type is None: - piece_type = random.choice(_CHARACTER_TYPE_TIP_KEYS) - piece_name = PIECE_TYPE_TO_NAME[piece_type] - unit_left_side_role = cast( - "PieceRole", _CHARACTER_TYPE_ROLE_MAPPING[piece_type].upper() - ) - unit_right_side_role = _CHARACTER_TYPE_ROLE_MAPPING[piece_type] - unit_display_left = unit_display_container( - piece_role=unit_left_side_role, factions=factions, row_counter=row_counter - ) - unit_display_right = unit_display_container( - piece_role=unit_right_side_role, factions=factions, row_counter=row_counter - ) - - return div( - unit_display_left, - div( - character_type_tip(piece_type), - chess_unit_symbol_display(player_side="w", piece_name=piece_name), - cls="text-center", - ), - unit_display_right, - cls=f"flex w-full justify-between items-center {additional_classes}", - ) - - -def unit_display_container( - *, piece_role: "PieceRole", factions: "GameFactions", row_counter: int | None = None -) -> "dom_tag": - from apps.chess.components.chess_board import chess_unit_display_with_ground_marker - - unit_display = chess_unit_display_with_ground_marker( - piece_role=piece_role, - factions=factions, - ) - - additional_classes = ( - f"{SQUARE_COLOR_TAILWIND_CLASSES[row_counter%2]} rounded-lg" - if row_counter is not None - else "" - ) - - return div( - unit_display, - cls=f"h-16 aspect-square {additional_classes}", - ) - - -def character_type_tip(piece_type: "PieceType") -> "dom_tag": - return raw( - f"{_CHARACTER_TYPE_TIP[piece_type]} are chess {PIECE_TYPE_TO_NAME[piece_type]}s" - ) - - -def chess_unit_symbol_display( - *, player_side: "PlayerSide", piece_name: "PieceName" -) -> "dom_tag": - classes = ( - "inline-block", - "w-5", - "align-text-bottom", - "aspect-square", - "bg-no-repeat", - "bg-cover", - chess_unit_symbol_class(player_side=player_side, piece_name=piece_name), - ) - - return span( - cls=" ".join(classes), - ) diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/misc_ui/status_bar.py index 4ff00b4..127bf73 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/status_bar.py @@ -9,13 +9,15 @@ type_from_piece_role, ) from apps.daily_challenge.components.misc_ui.help import ( - character_type_tip, chess_status_bar_tip, - chess_unit_symbol_display, help_content, unit_display_container, ) from apps.webui.components import common_styles +from apps.webui.components.chess_units import ( + character_type_tip, + chess_unit_symbol_display, +) if TYPE_CHECKING: from dominate.tags import dom_tag diff --git a/src/apps/lichess_bridge/components/game_creation.py b/src/apps/lichess_bridge/components/game_creation.py new file mode 100644 index 0000000..ebbc98a --- /dev/null +++ b/src/apps/lichess_bridge/components/game_creation.py @@ -0,0 +1,62 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import button, div, fieldset, form, input_, label, legend, p + +from apps.lichess_bridge.components.svg_icons import ICON_SVG_CREATE +from apps.lichess_bridge.models import LichessCorrespondenceGameDaysChoice +from apps.webui.components import common_styles +from apps.webui.components.forms_common import csrf_hidden_input + +if TYPE_CHECKING: + from django.http import HttpRequest + + +def game_creation_form(*, request: "HttpRequest", form_errors: dict) -> form: + return form( + csrf_hidden_input(request), + div( + fieldset( + legend("Days per move:", cls="font-bold"), + ( + p(form_errors["days_per_turn"], cls="text-red-600 ") + if "days_per_turn" in form_errors + else "" + ), + div( + *[ + div( + input_( + id=f"days-per-turn-{value}", + type="radio", + name="days_per_turn", + value=value, + checked=( + value + == LichessCorrespondenceGameDaysChoice.THREE_DAYS.value # type: ignore[attr-defined] + ), + ), + label(display, html_for=f"days-per-turn-{value}"), + cls="w-1/4 flex-none", + ) + for value, display in LichessCorrespondenceGameDaysChoice.choices + ], + cls="flex flex-wrap", + ), + cls="block text-sm font-bold mb-2", + ), + cls="mb-8", + ), + div( + button( + "Create", + " ", + ICON_SVG_CREATE, + type="submit", + cls=common_styles.BUTTON_CLASSES, + ), + cls="text-center", + ), + action=reverse("lichess_bridge:create_game"), + method="POST", + ) diff --git a/src/apps/lichess_bridge/components/ongoing_games.py b/src/apps/lichess_bridge/components/ongoing_games.py index d88154d..cb8881d 100644 --- a/src/apps/lichess_bridge/components/ongoing_games.py +++ b/src/apps/lichess_bridge/components/ongoing_games.py @@ -1,7 +1,9 @@ +from textwrap import dedent from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import a, b, caption, div, table, tbody, td, th, thead, tr +from dominate.tags import a, b, caption, div, script, table, tbody, td, th, thead, tr +from dominate.util import raw from apps.chess.chess_helpers import get_turns_counter_from_fen @@ -12,6 +14,36 @@ from ..models import LichessOngoingGameData +_CLICK_ON_TR_SCRIPT = script( + # quick-n-dirty script to allow one to click anywhere on a + # to go to the game page + raw( + dedent( + """ + { + function onRowClick(event) { + const row = event.currentTarget; + const link = row.querySelector("a"); + if (link) { + link.click(); + } + } + + function initRowClickBehaviour() { + const rows = document.querySelectorAll("#lichess-ongoing-games tbody tr"); + rows.forEach(row => { + row.addEventListener("click", onRowClick); + row.classList.add("cursor-pointer"); + }); + } + + document.addEventListener("DOMContentLoaded", initRowClickBehaviour); + } + """ + ) + ) +) + def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "html_tag": th_classes = "p-2" @@ -23,7 +55,7 @@ def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "htm tr( th("Opponent", cls=th_classes), th("Moves", cls=th_classes), - th("Time", cls=th_classes), + th("Time left for next move", cls=th_classes), th("Turn", cls=th_classes), cls="bg-rose-900 text-slate-200 font-bold", ), @@ -41,8 +73,10 @@ def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "htm ) ), ), + id="lichess-ongoing-games", cls="w-full border-separate border-spacing-0 border border-slate-500 rounded-md", ), + _CLICK_ON_TR_SCRIPT, cls="my-4 px-1 ", ) @@ -63,5 +97,8 @@ def _ongoing_game_row(game: "LichessOngoingGameData") -> tr: ), td(get_turns_counter_from_fen(game.fen), cls=f"{td_classes} text-right"), td(time_left_display(game.secondsLeft), cls=f"{td_classes} text-right"), - td(b("Mine") if game.isMyTurn else "Theirs", cls=f"{td_classes} text-right"), + td( + b("Mine", cls="text-slate-50") if game.isMyTurn else "Theirs", + cls=f"{td_classes} text-right", + ), ) diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index ef50783..c05394d 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -4,14 +4,12 @@ from django.urls import reverse from dominate.tags import ( a, + b, + br, button, div, - fieldset, form, h3, - input_, - label, - legend, p, section, span, @@ -24,15 +22,19 @@ chess_pieces, ) from apps.chess.components.misc_ui import speech_bubble_container +from apps.chess.models import GameFactions from apps.webui.components import common_styles +from apps.webui.components.chess_units import ( + unit_display_container, +) from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page from apps.webui.components.misc_ui.header import header_button from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button -from ...models import LichessCorrespondenceGameDaysChoice +from ..game_creation import game_creation_form from ..ongoing_games import lichess_ongoing_games -from ..svg_icons import ICON_SVG_CREATE, ICON_SVG_LOG_IN, ICON_SVG_USER +from ..svg_icons import ICON_SVG_LOG_IN, ICON_SVG_USER if TYPE_CHECKING: from django.http import HttpRequest @@ -44,27 +46,89 @@ ) from ...presenters import LichessCorrespondenceGamePresenter +_PAGE_TITLE_CSS = "mb-8 text-center font-bold text-yellow-400" +_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS = ( + "w-full mx-auto py-4 bg-slate-900 text-slate-50 min-h-48 md:max-w-3xl" +) +_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS = "px-8 pb-8 md:px-0 md:w-8/12 md:mx-auto" + def lichess_no_account_linked_page( *, request: "HttpRequest", ) -> str: + game_factions = GameFactions(w="humans", b="undeads") + return page( section( - form( - csrf_hidden_input(request), - p("Click here to log in to Lichess"), - button( - "Log in via Lichess", - " ", - ICON_SVG_LOG_IN, - type="submit", - cls=common_styles.BUTTON_CLASSES, + div( + h3( + "Play games on ZakuChess with your Lichess account", + cls=_PAGE_TITLE_CSS, + ), + p( + "You can play games with your friends and other people all around the world on ZakuChess, " + "by linking your Lichess account.", + cls="mb-4 text-center", + ), + p( + "This will allow you to play Lichess games via ZakuChess' boards, " + "where chess pieces are played by pixel art characters 🙂", + cls="mb-4 text-center", + ), + div( + unit_display_container( + piece_role="K", factions=game_factions, row_counter=0 + ), + unit_display_container( + piece_role="Q", factions=game_factions, row_counter=1 + ), + unit_display_container( + piece_role="N1", factions=game_factions, row_counter=0 + ), + div("VS", cls="grow px-4 text-center"), + unit_display_container( + piece_role="n1", factions=game_factions, row_counter=0 + ), + unit_display_container( + piece_role="q", factions=game_factions, row_counter=1 + ), + unit_display_container( + piece_role="k", factions=game_factions, row_counter=0 + ), + cls="flex justify-center items-center gap-1 md:gap-3", ), - action=reverse("lichess_bridge:oauth2_start_flow"), - method="POST", + form( + csrf_hidden_input(request), + p( + b("Click here to log in to Lichess"), + cls="mb-4 text-center font-bold", + ), + p( + button( + "Log in via Lichess", + " ", + ICON_SVG_LOG_IN, + type="submit", + cls=common_styles.BUTTON_CLASSES, + ), + cls="mb-4 text-center", + ), + action=reverse("lichess_bridge:oauth2_start_flow"), + method="POST", + cls="my-8", + ), + p( + "You will be able to disconnect your Lichess account from ZakuChess at any time.", + br(), + b( + "None of your Lichess data is stored on our end: it is only stored in your web browser." + ), + cls="mt-8 text-center text-sm", + ), + cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, ), - cls="text-slate-50", + cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, ), request=request, title="Lichess - no account linked", @@ -79,11 +143,11 @@ def lichess_my_current_games_list_page( ongoing_games: "list[LichessOngoingGameData]", ) -> str: return page( - div( - section( + section( + div( h3( "Your ongoing games on Lichess", - cls="text-slate-50 font-bold text-center", + cls=_PAGE_TITLE_CSS, ), lichess_ongoing_games(ongoing_games), p( @@ -94,9 +158,10 @@ def lichess_my_current_games_list_page( ), cls="my-8 text-center text-slate-50", ), + _lichess_account_footer(me), + cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, ), - _lichess_account_footer(me), - cls="w-full mx-auto bg-slate-900 min-h-48 pb-4 md:max-w-3xl", + cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, ), request=request, title="Lichess - account linked", @@ -105,59 +170,23 @@ def lichess_my_current_games_list_page( def lichess_correspondence_game_creation_page( - request: "HttpRequest", *, me: "LichessAccountInformation", form_errors: dict + request: "HttpRequest", + *, + me: "LichessAccountInformation", + form_errors: dict, ) -> str: return page( - div( - section( - form( - csrf_hidden_input(request), - div( - fieldset( - legend("Days per turn."), - ( - p(form_errors["days_per_turn"], cls="text-red-600 ") - if "days_per_turn" in form_errors - else "" - ), - div( - *[ - div( - input_( - id=f"days-per-turn-{value}", - type="radio", - name="days_per_turn", - value=value, - checked=( - value - == LichessCorrespondenceGameDaysChoice.THREE_DAYS.value # type: ignore[attr-defined] - ), - ), - label( - display, html_for=f"days-per-turn-{value}" - ), - ) - for value, display in LichessCorrespondenceGameDaysChoice.choices - ], - cls="flex gap-3", - ), - cls="block text-sm font-bold mb-2", - ), - ), - button( - "Create", - " ", - ICON_SVG_CREATE, - type="submit", - cls=common_styles.BUTTON_CLASSES, - ), - action=reverse("lichess_bridge:create_game"), - method="POST", + section( + div( + h3( + "New correspondence game, via Lichess", + cls=_PAGE_TITLE_CSS, ), + game_creation_form(request=request, form_errors=form_errors), _lichess_account_footer(me), - cls="text-slate-50", + cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, ), - cls="w-full mx-auto bg-slate-900 min-h-48 md:max-w-3xl", + cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, ), request=request, title="Lichess - new correspondence game", @@ -226,10 +255,19 @@ def lichess_game_moving_parts_fragment( def _lichess_account_footer(me: "LichessAccountInformation") -> "dom_tag": - return p( - "Your Lichess account: ", - span(me.username, cls="text-yellow-400"), - cls="mt-8 mb-4 text-slate-50 text-center text-sm", + return div( + p( + "Your Lichess account: ", + span(me.username, cls="text-yellow-400"), + cls="w-9/12 mx-auto mt-8 mb-4 text-slate-50 text-center text-sm", + ), + p( + "You can disconnect your Lichess account from ZakuChess at any time " + "in your 'user account' ", + ICON_SVG_USER, + " settings, accessible from the top menu.", + cls="w-9/12 mx-auto text-slate-50 text-center text-sm", + ), ) diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index aac9fe3..e4735eb 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -10,7 +10,7 @@ # Gameplay Views: path( "games/new/", - views.lichess_game_create, + views.lichess_game_create_form_page, name="create_game", ), path( diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 0948989..3487c72 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -83,7 +83,7 @@ async def lichess_my_games_list_page( @require_http_methods(["GET", "POST"]) @with_lichess_access_token @redirect_if_no_lichess_access_token -async def lichess_game_create( +async def lichess_game_create_form_page( request: "HttpRequest", *, lichess_access_token: "LichessAccessToken" ) -> HttpResponse: me = await _get_me_from_lichess(lichess_access_token) diff --git a/src/apps/webui/components/chess_units.py b/src/apps/webui/components/chess_units.py new file mode 100644 index 0000000..075f332 --- /dev/null +++ b/src/apps/webui/components/chess_units.py @@ -0,0 +1,120 @@ +import random +from typing import TYPE_CHECKING, cast + +from dominate.tags import div, span +from dominate.util import raw + +from apps.chess.components.chess_board import SQUARE_COLOR_TAILWIND_CLASSES +from apps.chess.components.chess_helpers import chess_unit_symbol_class +from apps.chess.consts import PIECE_TYPE_TO_NAME + +if TYPE_CHECKING: + from dominate.tags import dom_tag + + from apps.chess.models import GameFactions + from apps.chess.types import ( + PieceName, + PieceRole, + PieceType, + PlayerSide, + TeamMemberRole, + ) + +CHARACTER_TYPE_TIP: dict["PieceType", str] = { + # TODO: i18n + "p": "Characters with swords", + "n": "Mounted characters", + "b": "Characters with a bow", + "r": "Flying characters", + "q": "Characters with a staff", + "k": "Characters wearing heavy armors", +} +_CHARACTER_TYPE_TIP_KEYS = tuple(CHARACTER_TYPE_TIP.keys()) + +_CHARACTER_TYPE_ROLE_MAPPING: dict["PieceType", "TeamMemberRole"] = { + "p": "p1", + "n": "n1", + "b": "b1", + "r": "r1", + "q": "q", + "k": "k", +} + + +def chess_status_bar_tip( + *, + factions: "GameFactions", + piece_type: "PieceType | None" = None, + additional_classes: str = "", + row_counter: int | None = None, +) -> "dom_tag": + if piece_type is None: + piece_type = random.choice(_CHARACTER_TYPE_TIP_KEYS) + piece_name = PIECE_TYPE_TO_NAME[piece_type] + unit_left_side_role = cast( + "PieceRole", _CHARACTER_TYPE_ROLE_MAPPING[piece_type].upper() + ) + unit_right_side_role = _CHARACTER_TYPE_ROLE_MAPPING[piece_type] + unit_display_left = unit_display_container( + piece_role=unit_left_side_role, factions=factions, row_counter=row_counter + ) + unit_display_right = unit_display_container( + piece_role=unit_right_side_role, factions=factions, row_counter=row_counter + ) + + return div( + unit_display_left, + div( + character_type_tip(piece_type), + chess_unit_symbol_display(player_side="w", piece_name=piece_name), + cls="text-center", + ), + unit_display_right, + cls=f"flex w-full justify-between items-center {additional_classes}", + ) + + +def unit_display_container( + *, piece_role: "PieceRole", factions: "GameFactions", row_counter: int | None = None +) -> "dom_tag": + from apps.chess.components.chess_board import chess_unit_display_with_ground_marker + + unit_display = chess_unit_display_with_ground_marker( + piece_role=piece_role, + factions=factions, + ) + + additional_classes = ( + f"{SQUARE_COLOR_TAILWIND_CLASSES[row_counter%2]} rounded-lg" + if row_counter is not None + else "" + ) + + return div( + unit_display, + cls=f"h-16 aspect-square {additional_classes}", + ) + + +def character_type_tip(piece_type: "PieceType") -> "dom_tag": + return raw( + f"{CHARACTER_TYPE_TIP[piece_type]} are chess {PIECE_TYPE_TO_NAME[piece_type]}s" + ) + + +def chess_unit_symbol_display( + *, player_side: "PlayerSide", piece_name: "PieceName" +) -> "dom_tag": + classes = ( + "inline-block", + "w-5", + "align-text-bottom", + "aspect-square", + "bg-no-repeat", + "bg-cover", + chess_unit_symbol_class(player_side=player_side, piece_name=piece_name), + ) + + return span( + cls=" ".join(classes), + ) diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index a9f8d17..a8e50f4 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -128,7 +128,7 @@ def head(*children: "dom_tag", title: str) -> "dom_tag": link( # automatically created by `django-google-fonts` rel="stylesheet", - href=static("fonts/opensans::ital,wght@0,300..800;1,300..800.css"), + href=static("fonts/opensans:ital,wght@0,300..800;1,300..800.css"), ), # CSS & JS link(rel="stylesheet", href=static("webui/css/zakuchess.css")), diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index ce966b9..c05fd54 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -195,7 +195,7 @@ # Google fonts to mirror locally: # https://github.com/andymckay/django-google-fonts GOOGLE_FONTS = ( - "Open Sans::ital,wght@0,300..800;1,300..800", # https://fonts.google.com/specimen/Open+Sans + "Open Sans:ital,wght@0,300..800;1,300..800", # https://fonts.google.com/specimen/Open+Sans ) GOOGLE_FONTS_DIR = BASE_DIR / "src" / "apps" / "webui" / "static" From 891e3e988c05381f7d938f523428f6c5e110387b Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 13 Sep 2024 16:19:27 +0100 Subject: [PATCH 27/42] [Docker] Hotfix for the Gunicorn/Uvicorn setup --- scripts/start_server.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/start_server.sh b/scripts/start_server.sh index f713209..76a9dd3 100644 --- a/scripts/start_server.sh +++ b/scripts/start_server.sh @@ -23,4 +23,4 @@ echo "Make sure the SQLite database is always optimised." # Go! echo "Starting Gunicorn." -.venv/bin/gunicorn project.wsgi +.venv/bin/gunicorn project.asgi:application From bf3f8ac5dff010cc1eae04ecb026dc24f918752e Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Fri, 13 Sep 2024 16:35:06 +0100 Subject: [PATCH 28/42] [fix] Misc fixes after a first "pre-alpha" deploy to production --- Dockerfile | 1 + .../components/no_linked_account.py | 89 +++++++++++++++++++ .../components/pages/lichess_pages.py | 75 +--------------- src/apps/webui/components/chess_units.py | 10 ++- src/project/settings/flyio.py | 2 + 5 files changed, 102 insertions(+), 75 deletions(-) create mode 100644 src/apps/lichess_bridge/components/no_linked_account.py diff --git a/Dockerfile b/Dockerfile index 5354496..40334df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ COPY src/apps/chess/static-src ./src/apps/chess/static-src # so that Tailwind see the classes used in them: COPY src/apps/chess/components ./src/apps/chess/components COPY src/apps/daily_challenge/components ./src/apps/daily_challenge/components +COPY src/apps/lichess_bridge/components ./src/apps/lichess_bridge/components COPY src/apps/webui/components ./src/apps/webui/components # We're going to use our Makefile to build the assets: COPY Makefile ./ diff --git a/src/apps/lichess_bridge/components/no_linked_account.py b/src/apps/lichess_bridge/components/no_linked_account.py new file mode 100644 index 0000000..ca3a78e --- /dev/null +++ b/src/apps/lichess_bridge/components/no_linked_account.py @@ -0,0 +1,89 @@ +from typing import TYPE_CHECKING + +from django.urls import reverse +from dominate.tags import b, br, button, div, form, p + +from apps.chess.models import GameFactions +from apps.lichess_bridge.components.svg_icons import ICON_SVG_LOG_IN +from apps.webui.components import common_styles +from apps.webui.components.chess_units import unit_display_container +from apps.webui.components.forms_common import csrf_hidden_input + +if TYPE_CHECKING: + from django.http import HttpRequest + from dominate.tags import dom_tag + +_SHOWN_UNITS_FACTIONS = GameFactions(w="humans", b="undeads") + + +def no_linked_account_content(request: "HttpRequest") -> "dom_tag": + return div( + p( + "You can play games with your friends and other people all around the world on ZakuChess, " + "by linking your Lichess account.", + cls="mb-4 text-center", + ), + p( + "This will allow you to play Lichess games via ZakuChess' boards, " + "where chess pieces are played by pixel art characters 🙂", + cls="mb-4 text-center", + ), + div( + unit_display_container( + piece_role="K", factions=_SHOWN_UNITS_FACTIONS, row_counter=0 + ), + unit_display_container( + piece_role="Q", factions=_SHOWN_UNITS_FACTIONS, row_counter=1 + ), + unit_display_container( + piece_role="N1", + factions=_SHOWN_UNITS_FACTIONS, + row_counter=0, + # We don't have enough space on small screens to display all the units + additional_classes="hidden md:block", + ), + div("VS", cls="grow px-4 text-center"), + unit_display_container( + piece_role="n1", + factions=_SHOWN_UNITS_FACTIONS, + row_counter=0, + # ditto + additional_classes="hidden md:block", + ), + unit_display_container( + piece_role="q", factions=_SHOWN_UNITS_FACTIONS, row_counter=1 + ), + unit_display_container( + piece_role="k", factions=_SHOWN_UNITS_FACTIONS, row_counter=0 + ), + cls="flex justify-center items-center gap-1 md:gap-3", + ), + form( + csrf_hidden_input(request), + p( + b("Click here to log in to Lichess"), + cls="mb-4 text-center font-bold", + ), + p( + button( + "Log in via Lichess", + " ", + ICON_SVG_LOG_IN, + type="submit", + cls=common_styles.BUTTON_CLASSES, + ), + cls="mb-4 text-center", + ), + action=reverse("lichess_bridge:oauth2_start_flow"), + method="POST", + cls="my-8", + ), + p( + "You will be able to disconnect your Lichess account from ZakuChess at any time.", + br(), + b( + "None of your Lichess data is stored on our end: it is only stored in your web browser." + ), + cls="mt-8 text-center text-sm", + ), + ) diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index c05394d..902d14c 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -4,11 +4,7 @@ from django.urls import reverse from dominate.tags import ( a, - b, - br, - button, div, - form, h3, p, section, @@ -22,19 +18,15 @@ chess_pieces, ) from apps.chess.components.misc_ui import speech_bubble_container -from apps.chess.models import GameFactions from apps.webui.components import common_styles -from apps.webui.components.chess_units import ( - unit_display_container, -) -from apps.webui.components.forms_common import csrf_hidden_input from apps.webui.components.layout import page from apps.webui.components.misc_ui.header import header_button from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button from ..game_creation import game_creation_form +from ..no_linked_account import no_linked_account_content from ..ongoing_games import lichess_ongoing_games -from ..svg_icons import ICON_SVG_LOG_IN, ICON_SVG_USER +from ..svg_icons import ICON_SVG_USER if TYPE_CHECKING: from django.http import HttpRequest @@ -57,8 +49,6 @@ def lichess_no_account_linked_page( *, request: "HttpRequest", ) -> str: - game_factions = GameFactions(w="humans", b="undeads") - return page( section( div( @@ -66,66 +56,7 @@ def lichess_no_account_linked_page( "Play games on ZakuChess with your Lichess account", cls=_PAGE_TITLE_CSS, ), - p( - "You can play games with your friends and other people all around the world on ZakuChess, " - "by linking your Lichess account.", - cls="mb-4 text-center", - ), - p( - "This will allow you to play Lichess games via ZakuChess' boards, " - "where chess pieces are played by pixel art characters 🙂", - cls="mb-4 text-center", - ), - div( - unit_display_container( - piece_role="K", factions=game_factions, row_counter=0 - ), - unit_display_container( - piece_role="Q", factions=game_factions, row_counter=1 - ), - unit_display_container( - piece_role="N1", factions=game_factions, row_counter=0 - ), - div("VS", cls="grow px-4 text-center"), - unit_display_container( - piece_role="n1", factions=game_factions, row_counter=0 - ), - unit_display_container( - piece_role="q", factions=game_factions, row_counter=1 - ), - unit_display_container( - piece_role="k", factions=game_factions, row_counter=0 - ), - cls="flex justify-center items-center gap-1 md:gap-3", - ), - form( - csrf_hidden_input(request), - p( - b("Click here to log in to Lichess"), - cls="mb-4 text-center font-bold", - ), - p( - button( - "Log in via Lichess", - " ", - ICON_SVG_LOG_IN, - type="submit", - cls=common_styles.BUTTON_CLASSES, - ), - cls="mb-4 text-center", - ), - action=reverse("lichess_bridge:oauth2_start_flow"), - method="POST", - cls="my-8", - ), - p( - "You will be able to disconnect your Lichess account from ZakuChess at any time.", - br(), - b( - "None of your Lichess data is stored on our end: it is only stored in your web browser." - ), - cls="mt-8 text-center text-sm", - ), + no_linked_account_content(request), cls=_NON_GAME_PAGE_SECTION_INNER_CONTAINER_CSS, ), cls=_NON_GAME_PAGE_MAIN_SECTION_BASE_CSS, diff --git a/src/apps/webui/components/chess_units.py b/src/apps/webui/components/chess_units.py index 075f332..91f4def 100644 --- a/src/apps/webui/components/chess_units.py +++ b/src/apps/webui/components/chess_units.py @@ -75,7 +75,11 @@ def chess_status_bar_tip( def unit_display_container( - *, piece_role: "PieceRole", factions: "GameFactions", row_counter: int | None = None + *, + piece_role: "PieceRole", + factions: "GameFactions", + row_counter: int | None = None, + additional_classes: str = "", ) -> "dom_tag": from apps.chess.components.chess_board import chess_unit_display_with_ground_marker @@ -84,7 +88,7 @@ def unit_display_container( factions=factions, ) - additional_classes = ( + rounded_square_classes = ( f"{SQUARE_COLOR_TAILWIND_CLASSES[row_counter%2]} rounded-lg" if row_counter is not None else "" @@ -92,7 +96,7 @@ def unit_display_container( return div( unit_display, - cls=f"h-16 aspect-square {additional_classes}", + cls=f"h-16 aspect-square {rounded_square_classes} {additional_classes}", ) diff --git a/src/project/settings/flyio.py b/src/project/settings/flyio.py index 66ada54..83cc8ac 100644 --- a/src/project/settings/flyio.py +++ b/src/project/settings/flyio.py @@ -1,6 +1,8 @@ from .production import * USE_X_FORWARDED_HOST = True # Fly.io always sends request through a proxy +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + # @link https://django-axes.readthedocs.io/en/latest/4_configuration.html#configuring-reverse-proxies AXES_IPWARE_PROXY_COUNT = 1 From bca6e34cfae4cb1e8d9e8c730498d36d6a5ce6c8 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sun, 15 Sep 2024 18:47:09 +0100 Subject: [PATCH 29/42] [hotfix] Fix a bug in our Django Admin daily challenge edition UI --- src/apps/daily_challenge/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/daily_challenge/admin.py b/src/apps/daily_challenge/admin.py index d5c5e4a..2d9633c 100644 --- a/src/apps/daily_challenge/admin.py +++ b/src/apps/daily_challenge/admin.py @@ -419,7 +419,7 @@ def _get_game_presenter( ) challenge_preview = DailyChallenge( fen=fen, - teams=game_teams, + teams=game_teams.to_dict(), piece_role_by_square=piece_role_by_square, ) setattr(challenge_preview, "max_turns_count", 40) # we need this to return a value From d60de660c06d1ec4babbb0b616afd1af53063115 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 28 Sep 2024 12:27:02 +0100 Subject: [PATCH 30/42] [hotfix] Fix a regression with the new "NamedTuples" management of Team Members --- Makefile | 17 ++++++++-- src/apps/chess/models.py | 14 +++----- ...16_convert_games_team_members_to_tuples.py | 33 +++++++++++++++++++ src/apps/daily_challenge/tests/conftest.py | 15 +++++---- src/apps/daily_challenge/tests/test_views.py | 4 +-- 5 files changed, 63 insertions(+), 20 deletions(-) create mode 100644 src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py diff --git a/Makefile b/Makefile index 55c0f80..ac12092 100644 --- a/Makefile +++ b/Makefile @@ -62,11 +62,17 @@ backend/watch: ## Start Django via Uvicorn, in "watch" mode project.asgi:application .PHONY: backend/resetdb -backend/resetdb: dotenv_file ?= .env.local -backend/resetdb: # Destroys the SQLite database and recreates it from scratch +backend/resetdb: .confirm # Destroys the SQLite database and recreates it from scratch rm -f db.sqlite3 @${SUB_MAKE} db.sqlite3 +.PHONY: backend/backupdb +backend/backupdb: backup_name ?= $$(date --iso-8601=seconds | cut -d + -f 1) +backend/backupdb: # Creates a backup of the SQLite database + @sqlite3 db.sqlite3 ".backup 'db.local.${backup_name}.backup.sqlite3'" + @echo "Backup created as 'db.local.${backup_name}.backup.sqlite3'" + + .PHONY: backend/createsuperuser backend/createsuperuser: dotenv_file ?= .env.local backend/createsuperuser: email ?= admin@zakuchess.localhost @@ -208,6 +214,13 @@ django/manage: .venv .env.local ## Run a Django management command ./node_modules: frontend/install +# Here starts the "Internal Makefile utils" stuff + +.PHONY: .confirm +.confirm: +# https://www.alexedwards.net/blog/a-time-saving-makefile-for-your-go-projects + @echo -n 'Are you sure? [y/N] ' && read ans && [ $${ans:-N} = y ] + # Here starts the "Lichess database" stuff data/lichess_db_puzzle.csv: ## Download the Lichess puzzles database (CSV format) diff --git a/src/apps/chess/models.py b/src/apps/chess/models.py index 3ae7a94..af9b63b 100644 --- a/src/apps/chess/models.py +++ b/src/apps/chess/models.py @@ -5,6 +5,8 @@ from django.db import models if TYPE_CHECKING: + from collections.abc import Sequence + from .types import Faction, GameTeamsDict, PlayerSide, TeamMemberRole @@ -75,7 +77,7 @@ def get_faction_for_side(self, item: "PlayerSide") -> "Faction": class TeamMember(NamedTuple): role: "TeamMemberRole" - name: tuple[str, ...] + name: "Sequence[str]" faction: "Faction | None" = None @@ -102,12 +104,6 @@ def from_dict(cls, data: "GameTeamsDict") -> "GameTeams": Used to re-hydrate the data from the database. """ return cls( - w=tuple( - TeamMember(**member) if isinstance(member, dict) else member # type: ignore[arg-type] - for member in data["w"] - ), - b=tuple( - TeamMember(**member) if isinstance(member, dict) else member # type: ignore[arg-type] - for member in data["b"] - ), + w=tuple(TeamMember(*member) for member in data["w"]), + b=tuple(TeamMember(*member) for member in data["b"]), ) diff --git a/src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py b/src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py new file mode 100644 index 0000000..f3e144a --- /dev/null +++ b/src/apps/daily_challenge/migrations/0016_convert_games_team_members_to_tuples.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.1 on 2024-09-28 11:00 +from typing import TYPE_CHECKING, cast + +from django.db import migrations + +from apps.chess.consts import PLAYER_SIDES + +if TYPE_CHECKING: + from apps.chess.types import GameTeamsDict + + +def _convert_existing_games_team_members_to_tuples(apps, schema_editor): + DailyChallenge = apps.get_model("daily_challenge", "DailyChallenge") + + for challenge in DailyChallenge.objects.filter(teams__isnull=False).iterator(): + teams = cast("GameTeamsDict", challenge.teams) + + if not isinstance(teams["w"][0], dict): + continue # this game already uses the new "tuples" format + + for side in PLAYER_SIDES: + # Convert the list of team-members-as-dicts to a list of tuples + teams[side] = [tuple(member.values()) for member in teams[side]] + + challenge.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("daily_challenge", "0015_dailychallengestats_returning_players_count"), + ] + + operations = [migrations.RunPython(_convert_existing_games_team_members_to_tuples)] diff --git a/src/apps/daily_challenge/tests/conftest.py b/src/apps/daily_challenge/tests/conftest.py index c2ec08c..c4b1990 100644 --- a/src/apps/daily_challenge/tests/conftest.py +++ b/src/apps/daily_challenge/tests/conftest.py @@ -1,5 +1,6 @@ import pytest +from ...chess.models import TeamMember from ..models import ( DailyChallenge, DailyChallengeStatus, @@ -54,15 +55,15 @@ def challenge_minimalist() -> DailyChallenge: piece_role_by_square=_MINIMALIST_GAME["piece_role_by_square"], teams={ "w": [ - {"role": "Q", "name": ["QUEEN", "1"], "faction": "humans"}, - {"role": "B1", "name": ["BISHOP", "1"], "faction": "humans"}, - {"role": "K", "name": ["KING", "1"], "faction": "humans"}, + TeamMember("q", ("QUEEN", "1"), "humans"), + TeamMember("b1", ("BISHOP", "1"), "humans"), + TeamMember("k", ("KING",), "humans"), ], "b": [ - {"role": "k", "name": "", "faction": "undeads"}, - {"role": "p1", "name": "", "faction": "undeads"}, - {"role": "p2", "name": "", "faction": "undeads"}, - {"role": "p3", "name": "", "faction": "undeads"}, + TeamMember("k", [], "undeads"), + TeamMember("p1", [], "undeads"), + TeamMember("p2", [], "undeads"), + TeamMember("p3", [], "undeads"), ], }, fen_before_bot_first_move="1k6/pp3Q2/7p/8/8/8/7B/K7 b - - 0 1", diff --git a/src/apps/daily_challenge/tests/test_views.py b/src/apps/daily_challenge/tests/test_views.py index fa7c0a3..7096d12 100644 --- a/src/apps/daily_challenge/tests/test_views.py +++ b/src/apps/daily_challenge/tests/test_views.py @@ -130,7 +130,7 @@ def test_htmx_game_select_piece_input_validation( "expected_team_member_name_display", ), ( - ("a1", "KING 1"), + ("a1", "KING"), ("f7", "QUEEN 1"), ), ) @@ -157,7 +157,7 @@ def test_htmx_game_select_piece_returned_html( response_html = response.content.decode() assert expected_team_member_name_display in response_html - not_expected_team_member_names_display = {"KING 1", "QUEEN 1", "BISHOP 1"} - { + not_expected_team_member_names_display = {"KING", "QUEEN 1", "BISHOP 1"} - { expected_team_member_name_display } for other_team_member_name in not_expected_team_member_names_display: From 68282fae21d6c1326f1fcd2bfb5549402bbf4da9 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 28 Sep 2024 12:27:42 +0100 Subject: [PATCH 31/42] [deps] Update "django-axes" --- pyproject.toml | 2 +- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3e6b9b7..f34c9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies= [ "dominate==2.*", "dj-database-url==2.*", "requests==2.*", - "django-axes[ipware]==6.*", + "django-axes[ipware]==6.5.*", "whitenoise==6.*", "django-import-export==4.*", "msgspec==0.18.*", diff --git a/uv.lock b/uv.lock index ed291eb..6cc7574 100644 --- a/uv.lock +++ b/uv.lock @@ -423,15 +423,15 @@ wheels = [ [[package]] name = "django-axes" -version = "6.1.1" +version = "6.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "asgiref" }, { name = "django" }, - { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/0c/5efb4ddf29afaf2353b6de1c4561d34bb462762af8a438c915d6d859facd/django-axes-6.1.1.tar.gz", hash = "sha256:cd1bc4f7becc8e9243eb4090dffa258d7d7125ca0ce3153b6ffc920bccbf2c3f", size = 244358 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/cc/383f9c247f65e5e2e90df4a2be0c982053684364349b51c1d1509d6e7a75/django_axes-6.5.2.tar.gz", hash = "sha256:c2c007d61a3de018ef97649350dc15e5663cd9def1a05de8eeb0fe6adc1ed2ab", size = 246681 } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/80/d2ac4181d65cc75199eed1a76a8526daaad4242707d5aaae7d220e5cc145/django_axes-6.1.1-py3-none-any.whl", hash = "sha256:29c48ff5f09046afd5e9a16e96d3bbb79f6c11c59f0a7bbd732559e60d0aa9fa", size = 64396 }, + { url = "https://files.pythonhosted.org/packages/da/89/340932f7ef3adeab4aa4a1824a7f094bd24be86025c62bd26c0183e0ff0c/django_axes-6.5.2-py3-none-any.whl", hash = "sha256:fab92b98032fff55d7d2e0fdfade2be189949b6590b9e0c90e6fd213dd70bb67", size = 68447 }, ] [package.optional-dependencies] @@ -1861,7 +1861,7 @@ requires-dist = [ { name = "dj-database-url", specifier = "==2.*" }, { name = "django", specifier = "==5.1.*" }, { name = "django-alive", specifier = "==1.*" }, - { name = "django-axes", extras = ["ipware"], specifier = "==6.*" }, + { name = "django-axes", extras = ["ipware"], specifier = "==6.5.*" }, { name = "django-extensions", marker = "extra == 'dev'", specifier = "==3.*" }, { name = "django-google-fonts", specifier = "==0.0.3" }, { name = "django-htmx", specifier = "==1.*" }, From 1d60d717d44ff9e732252f62c430ed9cffc8c326 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 28 Sep 2024 12:41:25 +0100 Subject: [PATCH 32/42] [UI] Experiment with better (??) ways to display black pieces' symbol --- src/apps/chess/components/chess_board.py | 7 ++++++- tailwind.config.js | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 92cb6f5..9ccca91 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -675,6 +675,10 @@ def chess_unit_symbol_display( is_knight, is_pawn = piece_type == "n", piece_type == "p" + # We always display a "w" symbol, because for some reason I find the board + # game clearer that way. We'll just make it a bit less white for "b" pieces. + unit_symbol_class = chess_unit_symbol_class(player_side="w", piece_name=piece_name) + symbol_class = ( # We have to do some ad-hoc adjustments for Knights and Pawns: "w-7" if (is_pawn or is_knight) else "w-8", @@ -687,7 +691,8 @@ def chess_unit_symbol_display( if player_side == "w" else "drop-shadow-piece-symbol-b" ), - chess_unit_symbol_class(player_side=player_side, piece_name=piece_name), + *(("brightness-60",) if player_side == "b" else []), + unit_symbol_class, ) symbol_display = div( cls=" ".join(symbol_class), diff --git a/tailwind.config.js b/tailwind.config.js index 30438e0..e5579bc 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -94,6 +94,9 @@ module.exports = { "potential-capture": borderFromDropShadow(PIECES_DROP_SHADOW_OFFSET, POTENTIAL_CAPTURE_COLOR), "speech-bubble": `0 0 2px ${SPEECH_BUBBLE_DROP_SHADOW_COLOR}`, }, + brightness: { + 60: ".6", + }, }, }, plugins: [], From dd9f2be490757969d08713b0a4d908303ef0b001 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 28 Sep 2024 12:55:26 +0100 Subject: [PATCH 33/42] [CI] Update the "orgoro/coverage" GH Action --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index acbaa0a..b39e253 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -46,7 +46,7 @@ jobs: # --cov-fail-under=60 --> we'll actually do that with the "Report coverage" step - name: "Report coverage" # @link https://github.com/orgoro/coverage - uses: "orgoro/coverage@v3.1" + uses: "orgoro/coverage@v3.2" continue-on-error: true with: coverageFile: coverage.xml From 77f2009e712791e3709831696b7869faba45e698 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 28 Sep 2024 13:13:29 +0100 Subject: [PATCH 34/42] [UI] Keep experimenting with better (??) ways to display black pieces' symbol --- src/apps/chess/components/chess_board.py | 13 ++++++------- tailwind.config.js | 11 +++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 9ccca91..17a5248 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -574,7 +574,7 @@ def chess_character_display( is_potential_capture = True # let's highlight checks in "see solution" mode horizontal_translation = ( - ("left-3" if is_knight else "left-0") + ("left-2" if (is_knight or is_king) else "left-0") if is_from_original_left_hand_side else "right-0" ) @@ -607,9 +607,9 @@ def chess_character_display( ) if is_highlighted else ( - "drop-shadow-piece-symbol-w" + "drop-shadow-piece-unit-w" if piece_player_side == "w" - else "drop-shadow-piece-symbol-b" + else "drop-shadow-piece-unit-b" ) ), "drop-shadow-potential-capture" if is_potential_capture else "", @@ -675,9 +675,9 @@ def chess_unit_symbol_display( is_knight, is_pawn = piece_type == "n", piece_type == "p" - # We always display a "w" symbol, because for some reason I find the board - # game clearer that way. We'll just make it a bit less white for "b" pieces. - unit_symbol_class = chess_unit_symbol_class(player_side="w", piece_name=piece_name) + unit_symbol_class = chess_unit_symbol_class( + player_side=player_side, piece_name=piece_name + ) symbol_class = ( # We have to do some ad-hoc adjustments for Knights and Pawns: @@ -691,7 +691,6 @@ def chess_unit_symbol_display( if player_side == "w" else "drop-shadow-piece-symbol-b" ), - *(("brightness-60",) if player_side == "b" else []), unit_symbol_class, ) symbol_display = div( diff --git a/tailwind.config.js b/tailwind.config.js index e5579bc..f234d6b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -11,9 +11,12 @@ const FACTIONS = ["humans", "undeads"] const PLAYABLE_SELECTION_COLOR = "#ffff00" const NON_PLAYABLE_SELECTION_COLOR = "#ffd000" const POTENTIAL_CAPTURE_COLOR = "#c00000" -const PIECE_SYMBOL_BORDER_OPACITY = Math.round(0.4 * 0xff).toString(16) // 40% of 255 +const PIECE_UNIT_BORDER_OPACITY = Math.round(0.5 * 0xff).toString(16) // 50% of 255 +const PIECE_SYMBOL_BORDER_OPACITY = Math.round(0.75 * 0xff).toString(16) // 75% of 255 +const PIECE_UNIT_W = `#065f46${PIECE_UNIT_BORDER_OPACITY}` // emerald-800 +const PIECE_UNIT_B = `#3730a3${PIECE_UNIT_BORDER_OPACITY}` // indigo-800 const PIECE_SYMBOL_W = `#065f46${PIECE_SYMBOL_BORDER_OPACITY}` // emerald-800 -const PIECE_SYMBOL_B = `#3730a3${PIECE_SYMBOL_BORDER_OPACITY}` // indigo-800 +const PIECE_SYMBOL_B = `#a855f7${PIECE_SYMBOL_BORDER_OPACITY}` // purple-500 const PIECES_DROP_SHADOW_OFFSET = 1 // px const SPEECH_BUBBLE_DROP_SHADOW_COLOR = "#fbbf24" // amber-400 @@ -82,8 +85,8 @@ module.exports = { size: "width, height", }, dropShadow: { - // "piece-symbol-w": `0 0 0.1rem ${PIECE_SYMBOL_W}`, - // "piece-symbol-b": `0 0 0.1rem ${PIECE_SYMBOL_B}`, + "piece-unit-w": borderFromDropShadow(1, PIECE_UNIT_W), + "piece-unit-b": borderFromDropShadow(1, PIECE_UNIT_B), "piece-symbol-w": borderFromDropShadow(1, PIECE_SYMBOL_W), "piece-symbol-b": borderFromDropShadow(1, PIECE_SYMBOL_B), "playable-selected-piece": borderFromDropShadow(PIECES_DROP_SHADOW_OFFSET, PLAYABLE_SELECTION_COLOR), From a9f8e493f13d358fa8cb0e3046a4973fc9fb5f71 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Mon, 25 Nov 2024 21:26:00 +0000 Subject: [PATCH 35/42] [bugfix] Fix the wrong highlight of the king in check --- src/apps/chess/components/chess_board.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 17a5248..97c701f 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -517,6 +517,11 @@ def chess_character_display( # Some data we'll need: piece_player_side = player_side_from_piece_role(piece_role) + belongs_to_active_player = ( + bool(piece_player_side == game_presenter.active_player_side) + if game_presenter + else False + ) is_my_turn = game_presenter.is_my_turn if game_presenter else False is_playable = is_my_turn and ( ( @@ -558,20 +563,12 @@ def chess_character_display( # Right, let's do this shall we? if ( is_king - and is_my_turn - and game_presenter - and game_presenter.solution_index is None - and game_presenter.is_check - ): - is_potential_capture = True # let's highlight our king if it's in check - elif ( - is_king - and is_my_turn and game_presenter - and game_presenter.solution_index is not None + and belongs_to_active_player and game_presenter.is_check ): - is_potential_capture = True # let's highlight checks in "see solution" mode + # let's always highlight a king if it's in check: + is_potential_capture = True horizontal_translation = ( ("left-2" if (is_knight or is_king) else "left-0") From 820c158de4118643e9549345e65410992e68a101 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 11:26:30 +0000 Subject: [PATCH 36/42] [cleaning] Remove the server-side chess engines We're not using them anyhow, since our bot ended up being based on a WebAssembly version of Stockfish, running in te player's own browser. --- src/lib/chess_engines/__init__.py | 0 src/lib/chess_engines/andoma/evaluate.py | 218 ----- .../chess_engines/andoma/movegeneration.py | 147 --- src/lib/chess_engines/sunfish/sunfish.py | 877 ------------------ src/lib/chess_engines/sunfish/tools.py | 316 ------- 5 files changed, 1558 deletions(-) delete mode 100644 src/lib/chess_engines/__init__.py delete mode 100644 src/lib/chess_engines/andoma/evaluate.py delete mode 100644 src/lib/chess_engines/andoma/movegeneration.py delete mode 100644 src/lib/chess_engines/sunfish/sunfish.py delete mode 100644 src/lib/chess_engines/sunfish/tools.py diff --git a/src/lib/chess_engines/__init__.py b/src/lib/chess_engines/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/chess_engines/andoma/evaluate.py b/src/lib/chess_engines/andoma/evaluate.py deleted file mode 100644 index 75fd006..0000000 --- a/src/lib/chess_engines/andoma/evaluate.py +++ /dev/null @@ -1,218 +0,0 @@ -# N.B. Copy-pasted from https://github.com/healeycodes/andoma -import chess - -# this module implement's Tomasz Michniewski's Simplified Evaluation Function -# https://www.chessprogramming.org/Simplified_Evaluation_Function -# note that the board layouts have been flipped and the top left square is A1 - -# fmt: off -piece_value = { - chess.PAWN: 100, - chess.ROOK: 500, - chess.KNIGHT: 320, - chess.BISHOP: 330, - chess.QUEEN: 900, - chess.KING: 20000 -} - -pawnEvalWhite = [ - 0, 0, 0, 0, 0, 0, 0, 0, - 5, 10, 10, -20, -20, 10, 10, 5, - 5, -5, -10, 0, 0, -10, -5, 5, - 0, 0, 0, 20, 20, 0, 0, 0, - 5, 5, 10, 25, 25, 10, 5, 5, - 10, 10, 20, 30, 30, 20, 10, 10, - 50, 50, 50, 50, 50, 50, 50, 50, - 0, 0, 0, 0, 0, 0, 0, 0 -] -pawnEvalBlack = list(reversed(pawnEvalWhite)) - -knightEval = [ - -50, -40, -30, -30, -30, -30, -40, -50, - -40, -20, 0, 0, 0, 0, -20, -40, - -30, 0, 10, 15, 15, 10, 0, -30, - -30, 5, 15, 20, 20, 15, 5, -30, - -30, 0, 15, 20, 20, 15, 0, -30, - -30, 5, 10, 15, 15, 10, 5, -30, - -40, -20, 0, 5, 5, 0, -20, -40, - -50, -40, -30, -30, -30, -30, -40, -50 -] - -bishopEvalWhite = [ - -20, -10, -10, -10, -10, -10, -10, -20, - -10, 5, 0, 0, 0, 0, 5, -10, - -10, 10, 10, 10, 10, 10, 10, -10, - -10, 0, 10, 10, 10, 10, 0, -10, - -10, 5, 5, 10, 10, 5, 5, -10, - -10, 0, 5, 10, 10, 5, 0, -10, - -10, 0, 0, 0, 0, 0, 0, -10, - -20, -10, -10, -10, -10, -10, -10, -20 -] -bishopEvalBlack = list(reversed(bishopEvalWhite)) - -rookEvalWhite = [ - 0, 0, 0, 5, 5, 0, 0, 0, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - -5, 0, 0, 0, 0, 0, 0, -5, - 5, 10, 10, 10, 10, 10, 10, 5, - 0, 0, 0, 0, 0, 0, 0, 0 -] -rookEvalBlack = list(reversed(rookEvalWhite)) - -queenEval = [ - -20, -10, -10, -5, -5, -10, -10, -20, - -10, 0, 0, 0, 0, 0, 0, -10, - -10, 0, 5, 5, 5, 5, 0, -10, - -5, 0, 5, 5, 5, 5, 0, -5, - 0, 0, 5, 5, 5, 5, 0, -5, - -10, 5, 5, 5, 5, 5, 0, -10, - -10, 0, 5, 0, 0, 0, 0, -10, - -20, -10, -10, -5, -5, -10, -10, -20 -] - -kingEvalWhite = [ - 20, 30, 10, 0, 0, 10, 30, 20, - 20, 20, 0, 0, 0, 0, 20, 20, - -10, -20, -20, -20, -20, -20, -20, -10, - 20, -30, -30, -40, -40, -30, -30, -20, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30, - -30, -40, -40, -50, -50, -40, -40, -30 -] -kingEvalBlack = list(reversed(kingEvalWhite)) - -kingEvalEndGameWhite = [ - 50, -30, -30, -30, -30, -30, -30, -50, - -30, -30, 0, 0, 0, 0, -30, -30, - -30, -10, 20, 30, 30, 20, -10, -30, - -30, -10, 30, 40, 40, 30, -10, -30, - -30, -10, 30, 40, 40, 30, -10, -30, - -30, -10, 20, 30, 30, 20, -10, -30, - -30, -20, -10, 0, 0, -10, -20, -30, - -50, -40, -30, -20, -20, -30, -40, -50 -] -kingEvalEndGameBlack = list(reversed(kingEvalEndGameWhite)) -# fmt: on - - -def move_value(board: chess.Board, move: chess.Move, endgame: bool) -> float: - """ - How good is a move? - A promotion is great. - A weaker piece taking a stronger piece is good. - A stronger piece taking a weaker piece is bad. - Also consider the position change via piece-square table. - """ - if move.promotion is not None: - return -float("inf") if board.turn == chess.BLACK else float("inf") - - _piece = board.piece_at(move.from_square) - if _piece: - _from_value = evaluate_piece(_piece, move.from_square, endgame) - _to_value = evaluate_piece(_piece, move.to_square, endgame) - position_change = _to_value - _from_value - else: - raise Exception(f"A piece was expected at {move.from_square}") - - capture_value = 0.0 - if board.is_capture(move): - capture_value = evaluate_capture(board, move) - - current_move_value = capture_value + position_change - if board.turn == chess.BLACK: - current_move_value = -current_move_value - - return current_move_value - - -def evaluate_capture(board: chess.Board, move: chess.Move) -> float: - """ - Given a capturing move, weight the trade being made. - """ - if board.is_en_passant(move): - return piece_value[chess.PAWN] - _to = board.piece_at(move.to_square) - _from = board.piece_at(move.from_square) - if _to is None or _from is None: - raise Exception( - f"Pieces were expected at _both_ {move.to_square} and {move.from_square}" - ) - return piece_value[_to.piece_type] - piece_value[_from.piece_type] - - -def evaluate_piece(piece: chess.Piece, square: chess.Square, end_game: bool) -> int: - piece_type = piece.piece_type - mapping = [] - if piece_type == chess.PAWN: - mapping = pawnEvalWhite if piece.color == chess.WHITE else pawnEvalBlack - if piece_type == chess.KNIGHT: - mapping = knightEval - if piece_type == chess.BISHOP: - mapping = bishopEvalWhite if piece.color == chess.WHITE else bishopEvalBlack - if piece_type == chess.ROOK: - mapping = rookEvalWhite if piece.color == chess.WHITE else rookEvalBlack - if piece_type == chess.QUEEN: - mapping = queenEval - if piece_type == chess.KING: - # use end game piece-square tables if neither side has a queen - if end_game: - mapping = ( - kingEvalEndGameWhite - if piece.color == chess.WHITE - else kingEvalEndGameBlack - ) - else: - mapping = kingEvalWhite if piece.color == chess.WHITE else kingEvalBlack - - return mapping[square] - - -def evaluate_board(board: chess.Board) -> float: - """ - Evaluates the full board and determines which player is in a most favorable position. - The sign indicates the side: - (+) for white - (-) for black - The magnitude, how big of an advantage that player has - """ - total = 0 - end_game = check_end_game(board) - - for square in chess.SQUARES: - piece = board.piece_at(square) - if not piece: - continue - - value = piece_value[piece.piece_type] + evaluate_piece(piece, square, end_game) - total += value if piece.color == chess.WHITE else -value - - return total - - -def check_end_game(board: chess.Board) -> bool: - """ - Are we in the end game? - Per Michniewski: - - Both sides have no queens or - - Every side which has a queen has additionally no other pieces or one minorpiece maximum. - """ - queens = 0 - minors = 0 - - for square in chess.SQUARES: - piece = board.piece_at(square) - if piece and piece.piece_type == chess.QUEEN: - queens += 1 - if piece and ( - piece.piece_type == chess.BISHOP or piece.piece_type == chess.KNIGHT - ): - minors += 1 - - if queens == 0 or (queens == 2 and minors <= 1): - return True - - return False diff --git a/src/lib/chess_engines/andoma/movegeneration.py b/src/lib/chess_engines/andoma/movegeneration.py deleted file mode 100644 index edda3ce..0000000 --- a/src/lib/chess_engines/andoma/movegeneration.py +++ /dev/null @@ -1,147 +0,0 @@ -# N.B. Copy-pasted from https://github.com/healeycodes/andoma - -import time -from typing import Any, Dict, List - -import chess - -from .evaluate import check_end_game, evaluate_board, move_value - -debug_info: Dict[str, Any] = {} - - -MATE_SCORE = 1000000000 -MATE_THRESHOLD = 999000000 - - -def next_move(depth: int, board: chess.Board, debug=True) -> chess.Move: - """ - What is the next best move? - """ - debug_info.clear() - debug_info["nodes"] = 0 - t0 = time.time() - - move = minimax_root(depth, board) - - debug_info["time"] = time.time() - t0 - if debug: - print(f"info {debug_info}") - return move - - -def get_ordered_moves(board: chess.Board) -> List[chess.Move]: - """ - Get legal moves. - Attempt to sort moves by best to worst. - Use piece values (and positional gains/losses) to weight captures. - """ - end_game = check_end_game(board) - - def orderer(move): - return move_value(board, move, end_game) - - in_order = sorted( - board.legal_moves, key=orderer, reverse=(board.turn == chess.WHITE) - ) - return list(in_order) - - -def minimax_root(depth: int, board: chess.Board) -> chess.Move: - """ - What is the highest value move per our evaluation function? - """ - # White always wants to maximize (and black to minimize) - # the board score according to evaluate_board() - maximize = board.turn == chess.WHITE - best_move = -float("inf") - if not maximize: - best_move = float("inf") - - moves = get_ordered_moves(board) - best_move_found = moves[0] - - for move in moves: - board.push(move) - # Checking if draw can be claimed at this level, because the threefold repetition check - # can be expensive. This should help the bot avoid a draw if it's not favorable - # https://python-chess.readthedocs.io/en/latest/core.html#chess.Board.can_claim_draw - if board.can_claim_draw(): - value = 0.0 - else: - value = minimax(depth - 1, board, -float("inf"), float("inf"), not maximize) - board.pop() - if maximize and value >= best_move: - best_move = value - best_move_found = move - elif not maximize and value <= best_move: - best_move = value - best_move_found = move - - return best_move_found - - -def minimax( - depth: int, - board: chess.Board, - alpha: float, - beta: float, - is_maximising_player: bool, -) -> float: - """ - Core minimax logic. - https://en.wikipedia.org/wiki/Minimax - """ - debug_info["nodes"] += 1 - - if board.is_checkmate(): - # The previous move resulted in checkmate - return -MATE_SCORE if is_maximising_player else MATE_SCORE - # When the game is over and it's not a checkmate it's a draw - # In this case, don't evaluate. Just return a neutral result: zero - elif board.is_game_over(): - return 0 - - if depth == 0: - return evaluate_board(board) - - if is_maximising_player: - best_move = -float("inf") - moves = get_ordered_moves(board) - for move in moves: - board.push(move) - curr_move = minimax(depth - 1, board, alpha, beta, not is_maximising_player) - # Each ply after a checkmate is slower, so they get ranked slightly less - # We want the fastest mate! - if curr_move > MATE_THRESHOLD: - curr_move -= 1 - elif curr_move < -MATE_THRESHOLD: - curr_move += 1 - best_move = max( - best_move, - curr_move, - ) - board.pop() - alpha = max(alpha, best_move) - if beta <= alpha: - return best_move - return best_move - else: - best_move = float("inf") - moves = get_ordered_moves(board) - for move in moves: - board.push(move) - curr_move = minimax(depth - 1, board, alpha, beta, not is_maximising_player) - if curr_move > MATE_THRESHOLD: - curr_move -= 1 - elif curr_move < -MATE_THRESHOLD: - curr_move += 1 - best_move = min( - best_move, - curr_move, - ) - board.pop() - beta = min(beta, best_move) - if beta <= alpha: - return best_move - return best_move diff --git a/src/lib/chess_engines/sunfish/sunfish.py b/src/lib/chess_engines/sunfish/sunfish.py deleted file mode 100644 index f34ee42..0000000 --- a/src/lib/chess_engines/sunfish/sunfish.py +++ /dev/null @@ -1,877 +0,0 @@ -#!/usr/bin/env pypy -# -*- coding: utf-8 -*- - -# N.B. Copy-pasted from https://github.com/thomasahle/sunfish -# type: ignore - -from __future__ import print_function - -import re -import sys -import time -from collections import namedtuple -from itertools import count - -############################################################################### -# Piece-Square tables. Tune these to change sunfish's behaviour -############################################################################### - -piece = {"P": 100, "N": 280, "B": 320, "R": 479, "Q": 929, "K": 60000} -pst = { - "P": ( - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 78, - 83, - 86, - 73, - 102, - 82, - 85, - 90, - 7, - 29, - 21, - 44, - 40, - 31, - 44, - 7, - -17, - 16, - -2, - 15, - 14, - 0, - 15, - -13, - -26, - 3, - 10, - 9, - 6, - 1, - 0, - -23, - -22, - 9, - 5, - -11, - -10, - -2, - 3, - -19, - -31, - 8, - -7, - -37, - -36, - -14, - 3, - -31, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ), - "N": ( - -66, - -53, - -75, - -75, - -10, - -55, - -58, - -70, - -3, - -6, - 100, - -36, - 4, - 62, - -4, - -14, - 10, - 67, - 1, - 74, - 73, - 27, - 62, - -2, - 24, - 24, - 45, - 37, - 33, - 41, - 25, - 17, - -1, - 5, - 31, - 21, - 22, - 35, - 2, - 0, - -18, - 10, - 13, - 22, - 18, - 15, - 11, - -14, - -23, - -15, - 2, - 0, - 2, - 0, - -23, - -20, - -74, - -23, - -26, - -24, - -19, - -35, - -22, - -69, - ), - "B": ( - -59, - -78, - -82, - -76, - -23, - -107, - -37, - -50, - -11, - 20, - 35, - -42, - -39, - 31, - 2, - -22, - -9, - 39, - -32, - 41, - 52, - -10, - 28, - -14, - 25, - 17, - 20, - 34, - 26, - 25, - 15, - 10, - 13, - 10, - 17, - 23, - 17, - 16, - 0, - 7, - 14, - 25, - 24, - 15, - 8, - 25, - 20, - 15, - 19, - 20, - 11, - 6, - 7, - 6, - 20, - 16, - -7, - 2, - -15, - -12, - -14, - -15, - -10, - -10, - ), - "R": ( - 35, - 29, - 33, - 4, - 37, - 33, - 56, - 50, - 55, - 29, - 56, - 67, - 55, - 62, - 34, - 60, - 19, - 35, - 28, - 33, - 45, - 27, - 25, - 15, - 0, - 5, - 16, - 13, - 18, - -4, - -9, - -6, - -28, - -35, - -16, - -21, - -13, - -29, - -46, - -30, - -42, - -28, - -42, - -25, - -25, - -35, - -26, - -46, - -53, - -38, - -31, - -26, - -29, - -43, - -44, - -53, - -30, - -24, - -18, - 5, - -2, - -18, - -31, - -32, - ), - "Q": ( - 6, - 1, - -8, - -104, - 69, - 24, - 88, - 26, - 14, - 32, - 60, - -10, - 20, - 76, - 57, - 24, - -2, - 43, - 32, - 60, - 72, - 63, - 43, - 2, - 1, - -16, - 22, - 17, - 25, - 20, - -13, - -6, - -14, - -15, - -2, - -5, - -1, - -10, - -20, - -22, - -30, - -6, - -13, - -11, - -16, - -11, - -16, - -27, - -36, - -18, - 0, - -19, - -15, - -15, - -21, - -38, - -39, - -30, - -31, - -13, - -31, - -36, - -34, - -42, - ), - "K": ( - 4, - 54, - 47, - -99, - -99, - 60, - 83, - -62, - -32, - 10, - 55, - 56, - 56, - 55, - 10, - 3, - -62, - 12, - -57, - 44, - -67, - 28, - 37, - -31, - -55, - 50, - 11, - -4, - -19, - 13, - 0, - -49, - -55, - -43, - -52, - -28, - -51, - -47, - -8, - -50, - -47, - -42, - -43, - -79, - -64, - -32, - -29, - -32, - -4, - 3, - -14, - -50, - -57, - -18, - 13, - 4, - 17, - 30, - -3, - -14, - 6, - -1, - 40, - 18, - ), -} -# Pad tables and join piece and pst dictionaries -for k, table in pst.items(): - - def padrow(row): - return (0,) + tuple(x + piece[k] for x in row) + (0,) - - pst[k] = sum((padrow(table[i * 8 : i * 8 + 8]) for i in range(8)), ()) - pst[k] = (0,) * 20 + pst[k] + (0,) * 20 - -############################################################################### -# Global constants -############################################################################### - -# Our board is represented as a 120 character string. The padding allows for -# fast detection of moves that don't stay within the board. -A1, H1, A8, H8 = 91, 98, 21, 28 -initial = ( - " \n" # 0 - 9 - " \n" # 10 - 19 - " rnbqkbnr\n" # 20 - 29 - " pppppppp\n" # 30 - 39 - " ........\n" # 40 - 49 - " ........\n" # 50 - 59 - " ........\n" # 60 - 69 - " ........\n" # 70 - 79 - " PPPPPPPP\n" # 80 - 89 - " RNBQKBNR\n" # 90 - 99 - " \n" # 100 -109 - " \n" # 110 -119 -) - -# Lists of possible moves for each piece type. -N, E, S, W = -10, 1, 10, -1 -directions = { - "P": (N, N + N, N + W, N + E), - "N": ( - N + N + E, - E + N + E, - E + S + E, - S + S + E, - S + S + W, - W + S + W, - W + N + W, - N + N + W, - ), - "B": (N + E, S + E, S + W, N + W), - "R": (N, E, S, W), - "Q": (N, E, S, W, N + E, S + E, S + W, N + W), - "K": (N, E, S, W, N + E, S + E, S + W, N + W), -} - -# Mate value must be greater than 8*queen + 2*(rook+knight+bishop) -# King value is set to twice this value such that if the opponent is -# 8 queens up, but we got the king, we still exceed MATE_VALUE. -# When a MATE is detected, we'll set the score to MATE_UPPER - plies to get there -# E.g. Mate in 3 will be MATE_UPPER - 6 -MATE_LOWER = piece["K"] - 10 * piece["Q"] -MATE_UPPER = piece["K"] + 10 * piece["Q"] - -# The table size is the maximum number of elements in the transposition table. -TABLE_SIZE = 1e7 - -# Constants for tuning search -QS_LIMIT = 219 -EVAL_ROUGHNESS = 13 -DRAW_TEST = True - - -############################################################################### -# Chess logic -############################################################################### - - -class Position(namedtuple("Position", "board score wc bc ep kp")): - """A state of a chess game - board -- a 120 char representation of the board - score -- the board evaluation - wc -- the castling rights, [west/queen side, east/king side] - bc -- the opponent castling rights, [west/king side, east/queen side] - ep - the en passant square - kp - the king passant square - """ - - def gen_moves(self): - # For each of our pieces, iterate through each possible 'ray' of moves, - # as defined in the 'directions' map. The rays are broken e.g. by - # captures or immediately in case of pieces such as knights. - for i, p in enumerate(self.board): - if not p.isupper(): - continue - for d in directions[p]: - for j in count(i + d, d): - q = self.board[j] - # Stay inside the board, and off friendly pieces - if q.isspace() or q.isupper(): - break - # Pawn move, double move and capture - if p == "P" and d in (N, N + N) and q != ".": - break - if ( - p == "P" - and d == N + N - and (i < A1 + N or self.board[i + N] != ".") - ): - break - if ( - p == "P" - and d in (N + W, N + E) - and q == "." - and j not in (self.ep, self.kp, self.kp - 1, self.kp + 1) - ): - break - # Move it - yield (i, j) - # Stop crawlers from sliding, and sliding after captures - if p in "PNK" or q.islower(): - break - # Castling, by sliding the rook next to the king - if i == A1 and self.board[j + E] == "K" and self.wc[0]: - yield (j + E, j + W) - if i == H1 and self.board[j + W] == "K" and self.wc[1]: - yield (j + W, j + E) - - def rotate(self): - """Rotates the board, preserving enpassant""" - return Position( - self.board[::-1].swapcase(), - -self.score, - self.bc, - self.wc, - 119 - self.ep if self.ep else 0, - 119 - self.kp if self.kp else 0, - ) - - def nullmove(self): - """Like rotate, but clears ep and kp""" - return Position( - self.board[::-1].swapcase(), -self.score, self.bc, self.wc, 0, 0 - ) - - def move(self, move): - i, j = move - p, q = self.board[i], self.board[j] - - def put(board, i, p): - return board[:i] + p + board[i + 1 :] - - # Copy variables and reset ep and kp - board = self.board - wc, bc, ep, kp = self.wc, self.bc, 0, 0 - score = self.score + self.value(move) - # Actual move - board = put(board, j, board[i]) - board = put(board, i, ".") - # Castling rights, we move the rook or capture the opponent's - if i == A1: - wc = (False, wc[1]) - if i == H1: - wc = (wc[0], False) - if j == A8: - bc = (bc[0], False) - if j == H8: - bc = (False, bc[1]) - # Castling - if p == "K": - wc = (False, False) - if abs(j - i) == 2: - kp = (i + j) // 2 - board = put(board, A1 if j < i else H1, ".") - board = put(board, kp, "R") - # Pawn promotion, double move and en passant capture - if p == "P": - if A8 <= j <= H8: - board = put(board, j, "Q") - if j - i == 2 * N: - ep = i + N - if j == self.ep: - board = put(board, j + S, ".") - # We rotate the returned position, so it's ready for the next player - return Position(board, score, wc, bc, ep, kp).rotate() - - def value(self, move): - i, j = move - p, q = self.board[i], self.board[j] - # Actual move - score = pst[p][j] - pst[p][i] - # Capture - if q.islower(): - score += pst[q.upper()][119 - j] - # Castling check detection - if abs(j - self.kp) < 2: - score += pst["K"][119 - j] - # Castling - if p == "K" and abs(i - j) == 2: - score += pst["R"][(i + j) // 2] - score -= pst["R"][A1 if j < i else H1] - # Special pawn stuff - if p == "P": - if A8 <= j <= H8: - score += pst["Q"][j] - pst["P"][j] - if j == self.ep: - score += pst["P"][119 - (j + S)] - return score - - -############################################################################### -# Search logic -############################################################################### - -# lower <= s(pos) <= upper -Entry = namedtuple("Entry", "lower upper") - - -class Searcher: - def __init__(self): - self.tp_score = {} - self.tp_move = {} - self.history = set() - self.nodes = 0 - - def bound(self, pos, gamma, depth, root=True): - """returns r where - s(pos) <= r < gamma if gamma > s(pos) - gamma <= r <= s(pos) if gamma <= s(pos)""" - self.nodes += 1 - - # Depth <= 0 is QSearch. Here any position is searched as deeply as is needed for - # calmness, and from this point on there is no difference in behaviour depending on - # depth, so so there is no reason to keep different depths in the transposition table. - depth = max(depth, 0) - - # Sunfish is a king-capture engine, so we should always check if we - # still have a king. Notice since this is the only termination check, - # the remaining code has to be comfortable with being mated, stalemated - # or able to capture the opponent king. - if pos.score <= -MATE_LOWER: - return -MATE_UPPER - - # We detect 3-fold captures by comparing against previously - # _actually played_ positions. - # Note that we need to do this before we look in the table, as the - # position may have been previously reached with a different score. - # This is what prevents a search instability. - # FIXME: This is not true, since other positions will be affected by - # the new values for all the drawn positions. - if DRAW_TEST: - if not root and pos in self.history: - return 0 - - # Look in the table if we have already searched this position before. - # We also need to be sure, that the stored search was over the same - # nodes as the current search. - entry = self.tp_score.get((pos, depth, root), Entry(-MATE_UPPER, MATE_UPPER)) - if entry.lower >= gamma and (not root or self.tp_move.get(pos) is not None): - return entry.lower - if entry.upper < gamma: - return entry.upper - - # Here extensions may be added - # Such as 'if in_check: depth += 1' - - # Generator of moves to search in order. - # This allows us to define the moves, but only calculate them if needed. - def moves(): - # First try not moving at all. We only do this if there is at least one major - # piece left on the board, since otherwise zugzwangs are too dangerous. - if depth > 0 and not root and any(c in pos.board for c in "RBNQ"): - yield ( - None, - -self.bound(pos.nullmove(), 1 - gamma, depth - 3, root=False), - ) - # For QSearch we have a different kind of null-move, namely we can just stop - # and not capture anything else. - if depth == 0: - yield None, pos.score - # Then killer move. We search it twice, but the tp will fix things for us. - # Note, we don't have to check for legality, since we've already done it - # before. Also note that in QS the killer must be a capture, otherwise we - # will be non deterministic. - killer = self.tp_move.get(pos) - if killer and (depth > 0 or pos.value(killer) >= QS_LIMIT): - yield ( - killer, - -self.bound(pos.move(killer), 1 - gamma, depth - 1, root=False), - ) - # Then all the other moves - for move in sorted(pos.gen_moves(), key=pos.value, reverse=True): - # for val, move in sorted(((pos.value(move), move) for move in pos.gen_moves()), reverse=True): - # If depth == 0 we only try moves with high intrinsic score (captures and - # promotions). Otherwise we do all moves. - if depth > 0 or pos.value(move) >= QS_LIMIT: - yield ( - move, - -self.bound(pos.move(move), 1 - gamma, depth - 1, root=False), - ) - - # Run through the moves, shortcutting when possible - best = -MATE_UPPER - for move, score in moves(): - best = max(best, score) - if best >= gamma: - # Clear before setting, so we always have a value - if len(self.tp_move) > TABLE_SIZE: - self.tp_move.clear() - # Save the move for pv construction and killer heuristic - self.tp_move[pos] = move - break - - # Stalemate checking is a bit tricky: Say we failed low, because - # we can't (legally) move and so the (real) score is -infty. - # At the next depth we are allowed to just return r, -infty <= r < gamma, - # which is normally fine. - # However, what if gamma = -10 and we don't have any legal moves? - # Then the score is actaully a draw and we should fail high! - # Thus, if best < gamma and best < 0 we need to double check what we are doing. - # This doesn't prevent sunfish from making a move that results in stalemate, - # but only if depth == 1, so that's probably fair enough. - # (Btw, at depth 1 we can also mate without realizing.) - if best < gamma and best < 0 and depth > 0: - - def is_dead(pos): - return any(pos.value(m) >= MATE_LOWER for m in pos.gen_moves()) - - if all(is_dead(pos.move(m)) for m in pos.gen_moves()): - in_check = is_dead(pos.nullmove()) - best = -MATE_UPPER if in_check else 0 - - # Clear before setting, so we always have a value - if len(self.tp_score) > TABLE_SIZE: - self.tp_score.clear() - # Table part 2 - if best >= gamma: - self.tp_score[pos, depth, root] = Entry(best, entry.upper) - if best < gamma: - self.tp_score[pos, depth, root] = Entry(entry.lower, best) - - return best - - def search(self, pos, history=()): - """Iterative deepening MTD-bi search""" - self.nodes = 0 - if DRAW_TEST: - self.history = set(history) - # print('# Clearing table due to new history') - self.tp_score.clear() - - # In finished games, we could potentially go far enough to cause a recursion - # limit exception. Hence we bound the ply. - for depth in range(1, 1000): - # The inner loop is a binary search on the score of the position. - # Inv: lower <= score <= upper - # 'while lower != upper' would work, but play tests show a margin of 20 plays - # better. - lower, upper = -MATE_UPPER, MATE_UPPER - while lower < upper - EVAL_ROUGHNESS: - gamma = (lower + upper + 1) // 2 - score = self.bound(pos, gamma, depth) - if score >= gamma: - lower = score - if score < gamma: - upper = score - # We want to make sure the move to play hasn't been kicked out of the table, - # So we make another call that must always fail high and thus produce a move. - self.bound(pos, lower, depth) - # If the game hasn't finished we can retrieve our move from the - # transposition table. - yield ( - depth, - self.tp_move.get(pos), - self.tp_score.get((pos, depth, True)).lower, - ) - - -############################################################################### -# User interface -############################################################################### - -# Python 2 compatability -if sys.version_info[0] == 2: - input = raw_input - - -def parse(c): - fil, rank = ord(c[0]) - ord("a"), int(c[1]) - 1 - return A1 + fil - 10 * rank - - -def render(i): - rank, fil = divmod(i - A1, 10) - return chr(fil + ord("a")) + str(-rank + 1) - - -def print_pos(pos): - print() - uni_pieces = { - "R": "♜", - "N": "♞", - "B": "♝", - "Q": "♛", - "K": "♚", - "P": "♟", - "r": "♖", - "n": "♘", - "b": "♗", - "q": "♕", - "k": "♔", - "p": "♙", - ".": "·", - } - for i, row in enumerate(pos.board.split()): - print(" ", 8 - i, " ".join(uni_pieces.get(p, p) for p in row)) - print(" a b c d e f g h \n\n") - - -def main(): - hist = [Position(initial, 0, (True, True), (True, True), 0, 0)] - searcher = Searcher() - while True: - print_pos(hist[-1]) - - if hist[-1].score <= -MATE_LOWER: - print("You lost") - break - - # We query the user until she enters a (pseudo) legal move. - move = None - while move not in hist[-1].gen_moves(): - match = re.match("([a-h][1-8])" * 2, input("Your move: ")) - if match: - move = parse(match.group(1)), parse(match.group(2)) - else: - # Inform the user when invalid input (e.g. "help") is entered - print("Please enter a move like g8f6") - hist.append(hist[-1].move(move)) - - # After our move we rotate the board and print it again. - # This allows us to see the effect of our move. - print_pos(hist[-1].rotate()) - - if hist[-1].score <= -MATE_LOWER: - print("You won") - break - - # Fire up the engine to look for a move. - start = time.time() - for _depth, move, score in searcher.search(hist[-1], hist): - if time.time() - start > 1: - break - - if score == MATE_UPPER: - print("Checkmate!") - - # The black player moves from a rotated position, so we have to - # 'back rotate' the move before printing it. - print("My move:", render(119 - move[0]) + render(119 - move[1])) - hist.append(hist[-1].move(move)) - - -if __name__ == "__main__": - main() diff --git a/src/lib/chess_engines/sunfish/tools.py b/src/lib/chess_engines/sunfish/tools.py deleted file mode 100644 index 67a2e3b..0000000 --- a/src/lib/chess_engines/sunfish/tools.py +++ /dev/null @@ -1,316 +0,0 @@ -# N.B. Copy-pasted from https://github.com/thomasahle/sunfish - -import itertools -import re -import sys -import time - -from . import sunfish - -################################################################################ -# This module contains functions used by test.py and xboard.py. -# Nothing from here is imported into sunfish.py which is entirely self-sufficient -################################################################################ - -# Sunfish doesn't have to know about colors, but for more advanced things, such -# as xboard support, we have to. -WHITE, BLACK = range(2) - -FEN_INITIAL = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - - -def search(searcher, pos, secs, history=()): - """This used to be in the Searcher class""" - start = time.time() - for i, (depth, move, score) in enumerate(searcher.search(pos, history)): - if time.time() - start > secs: - break - return i, move, score, depth - - -################################################################################ -# Parse and Render moves -################################################################################ - - -def gen_legal_moves(pos): - """pos.gen_moves(), but without those that leaves us in check. - Also the position after moving is included.""" - for move in pos.gen_moves(): - pos1 = pos.move(move) - if not can_kill_king(pos1): - yield move, pos1 - - -def can_kill_king(pos): - # If we just checked for opponent moves capturing the king, we would miss - # captures in case of illegal castling. - return any(pos.value(m) >= sunfish.MATE_LOWER for m in pos.gen_moves()) - - -def mrender(pos, m): - # Sunfish always assumes promotion to queen - p = "q" if sunfish.A8 <= m[1] <= sunfish.H8 and pos.board[m[0]] == "P" else "" - m = m if get_color(pos) == WHITE else (119 - m[0], 119 - m[1]) - return sunfish.render(m[0]) + sunfish.render(m[1]) + p - - -def mparse(color, move): - m = (sunfish.parse(move[0:2]), sunfish.parse(move[2:4])) - return m if color == WHITE else (119 - m[0], 119 - m[1]) - - -def renderSAN(pos, move): - """Assumes board is rotated to position of current player""" - i, j = move - csrc, cdst = sunfish.render(i), sunfish.render(j) - # Rotate flor black - if get_color(pos) == BLACK: - csrc, cdst = sunfish.render(119 - i), sunfish.render(119 - j) - # Check - pos1 = pos.move(move) - - def cankill(p): - return any(p.board[b] == "k" for a, b in p.gen_moves()) - - check = "" - if cankill(pos1.rotate()): - check = "+" - if all(cankill(pos1.move(move1)) for move1 in pos1.gen_moves()): - check = "#" - # Castling - if pos.board[i] == "K" and abs(i - j) == 2: - if get_color(pos) == WHITE and j > i or get_color(pos) == BLACK and j < i: - return "O-O" + check - else: - return "O-O-O" + check - # Pawn moves - if pos.board[i] == "P": - pro = "=Q" if sunfish.A8 <= j <= sunfish.H8 else "" - cap = csrc[0] + "x" if pos.board[j] != "." or j == pos.ep else "" - return cap + cdst + pro + check - # Figure out what files and ranks we need to include - srcs = [ - a - for (a, b), _ in gen_legal_moves(pos) - if pos.board[a] == pos.board[i] and b == j - ] - srcs_file = [a for a in srcs if (a - sunfish.A1) % 10 == (i - sunfish.A1) % 10] - srcs_rank = [a for a in srcs if (a - sunfish.A1) // 10 == (i - sunfish.A1) // 10] - assert srcs, "No moves compatible with {}".format(move) - if len(srcs) == 1: - src = "" - elif len(srcs_file) == 1: - src = csrc[0] - elif len(srcs_rank) == 1: - src = csrc[1] - else: - src = csrc - # Normal moves - p = pos.board[i] - cap = "x" if pos.board[j] != "." else "" - return p + src + cap + cdst + check - - -def parseSAN(pos, msan): - """Assumes board is rotated to position of current player""" - # Normal moves - normal = re.match("([KQRBN])([a-h])?([1-8])?x?([a-h][1-8])", msan) - if normal: - p, fil, rank, dst = normal.groups() - src = (fil or "[a-h]") + (rank or "[1-8]") - # Pawn moves - pawn = re.match("([a-h])?x?([a-h][1-8])", msan) - if pawn: - assert not re.search( - "[RBN]$", msan - ), "Sunfish only supports queen promotion in {}".format(msan) - p, (fil, dst) = "P", pawn.groups() - src = (fil or "[a-h]") + "[1-8]" - # Castling - if re.match(msan, "O-O-O[+#]?"): - p, src, dst = "K", "e[18]", "c[18]" - if re.match(msan, "O-O[+#]?"): - p, src, dst = "K", "e[18]", "g[18]" - # Find possible match - assert "p" in vars(), "No piece to move with {}".format(msan) - for (i, j), _ in gen_legal_moves(pos): - if get_color(pos) == WHITE: - csrc, cdst = sunfish.render(i), sunfish.render(j) - else: - csrc, cdst = sunfish.render(119 - i), sunfish.render(119 - j) - if pos.board[i] == p and re.match(dst, cdst) and re.match(src, csrc): - return (i, j) - assert False, "Couldn't find legal move matching {}. Had {}".format( - msan, {"p": p, "src": src, "dst": dst, "mvs": list(gen_legal_moves(pos))} - ) - - -def readPGN(file): - """Yields a number of [(pos, move), ...] lists.""" - - def _parse_single_pgn(lines): - # Remove comments and numbers. - parts = re.sub("{.*?}", "", " ".join(lines)).split() - msans = [part for part in parts if not part[0].isdigit()] - pos = parseFEN(FEN_INITIAL) - for msan in msans: - try: - move = parseSAN(pos, msan) - except AssertionError: - print("PGN was:", " ".join(lines)) - raise - yield pos, move - pos = pos.move(move) - - # TODO: Currently assumes all games start at the initial position. - current_game = [] - for line in file: - if line.startswith("["): - if current_game: - yield " ".join(current_game), list(_parse_single_pgn(current_game)) - del current_game[:] - else: - current_game.append(line.strip()) - - -################################################################################ -# Parse and Render positions -################################################################################ - - -def get_color(pos): - """A slightly hacky way to to get the color from a sunfish position""" - return BLACK if pos.board.startswith("\n") else WHITE - - -def parseFEN(fen): - """Parses a string in Forsyth-Edwards Notation into a Position""" - board, color, castling, enpas, _hclock, _fclock = fen.split() - board = re.sub(r"\d", (lambda m: "." * int(m.group(0))), board) - board = list(21 * " " + " ".join(board.split("/")) + 21 * " ") - board[9::10] = ["\n"] * 12 - # if color == 'w': board[::10] = ['\n']*12 - # if color == 'b': board[9::10] = ['\n']*12 - board = "".join(board) - wc = ("Q" in castling, "K" in castling) - bc = ("k" in castling, "q" in castling) - ep = sunfish.parse(enpas) if enpas != "-" else 0 - score = sum(sunfish.pst[p][i] for i, p in enumerate(board) if p.isupper()) - score -= sum( - sunfish.pst[p.upper()][119 - i] for i, p in enumerate(board) if p.islower() - ) - pos = sunfish.Position(board, score, wc, bc, ep, 0) - return pos if color == "w" else pos.rotate() - - -def renderFEN(pos, half_move_clock=0, full_move_clock=1): - color = "wb"[get_color(pos)] - if get_color(pos) == BLACK: - pos = pos.rotate() - board = "/".join(pos.board.split()) - board = re.sub(r"\.+", (lambda m: str(len(m.group(0)))), board) - castling = "".join(itertools.compress("KQkq", pos.wc[::-1] + pos.bc)) or "-" - ep = sunfish.render(pos.ep) if not pos.board[pos.ep].isspace() else "-" - clock = "{} {}".format(half_move_clock, full_move_clock) - return " ".join((board, color, castling, ep, clock)) - - -def parseEPD(epd, opt_dict=False): - epd = epd.strip("\n ;").replace('"', "") - parts = epd.split(maxsplit=6) - opt_part = "" - if len(parts) >= 6 and parts[4].isdigit() and parts[5].isdigit(): - fen = " ".join(parts[:6]) - opt_part = " ".join(parts[6:]) - else: - # Sometimes fen doesn't include half move clocks - fen = " ".join(parts[:4]) + " 0 1" - opt_part = " ".join(parts[4:]) - # EPD operations may either be or ( ) - opts = opt_part.split(";") - if opt_dict: - opts = dict(p.split(maxsplit=1) for p in opts) - return fen, opts - - -################################################################################ -# Pretty print -################################################################################ - - -def pv(searcher, pos, include_scores=True, include_loop=False): - res = [] - seen_pos = set() - color = get_color(pos) - origc = color - if include_scores: - res.append(str(pos.score)) - while True: - move = searcher.tp_move.get(pos) - # The tp may have illegal moves, given lower depths don't detect king killing - if move is None or can_kill_king(pos.move(move)): - break - res.append(mrender(pos, move)) - pos, color = pos.move(move), 1 - color - if pos in seen_pos: - if include_loop: - res.append("loop") - break - seen_pos.add(pos) - if include_scores: - res.append(str(pos.score if color == origc else -pos.score)) - return " ".join(res) - - -################################################################################ -# Bulk move generation -################################################################################ - - -def expand_position(pos): - """Yields a tree of generators [p, [p, [...], ...], ...] rooted at pos""" - yield pos - for _, pos1 in gen_legal_moves(pos): - yield expand_position(pos1) - - -def collect_tree_depth(tree, depth): - """Yields positions exactly at depth""" - root = next(tree) - if depth == 0: - yield root - else: - for subtree in tree: - for pos in collect_tree_depth(subtree, depth - 1): - yield pos - - -def flatten_tree(tree, depth): - """Yields positions exactly at less than depth""" - if depth == 0: - return - yield next(tree) - for subtree in tree: - for pos in flatten_tree(subtree, depth - 1): - yield pos - - -################################################################################ -# Non chess related tools -################################################################################ - - -# Disable buffering -class Unbuffered(object): - def __init__(self, stream): - self.stream = stream - - def write(self, data): - self.stream.write(data) - self.stream.flush() - sys.stderr.write(data) - sys.stderr.flush() - - def __getattr__(self, attr): - return getattr(self.stream, attr) From 0ca4188e31cc17ae8270695f792f751ec3e466d6 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 11:31:52 +0000 Subject: [PATCH 37/42] [pre-commit] Add "fix-future-annotations" & "pyupgrade" hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the next major version of Python finally seems to land with PEP 649 implemented, it seems safe to use `from __future__ import annotations` everywhere we use non-builtin type hints :-) And thanks to the "flake8 type-checking" rules (re-implemented in Ruff), we can make sure we're still using "if TYPE_CHECKING:" for type-only imports 👌 --- .pre-commit-config.yaml | 25 ++++- Makefile | 11 +- pyproject.toml | 5 +- scripts/download_assets.py | 3 +- .../_calculate_fen_before_move.py | 8 +- .../_calculate_piece_available_targets.py | 8 +- .../chess/business_logic/_do_chess_move.py | 20 ++-- ...do_chess_move_with_piece_role_by_square.py | 20 ++-- src/apps/chess/chess_helpers.py | 50 ++++----- src/apps/chess/components/chess_board.py | 96 +++++++++-------- src/apps/chess/components/chess_helpers.py | 38 +++---- src/apps/chess/components/misc_ui.py | 20 ++-- src/apps/chess/consts.py | 20 ++-- src/apps/chess/models.py | 24 +++-- src/apps/chess/presenters.py | 86 +++++++-------- .../business_logic/test_do_chess_move.py | 16 +-- src/apps/chess/types.py | 10 +- src/apps/chess/url_converters.py | 6 +- src/apps/daily_challenge/admin.py | 46 ++++---- .../_compute_fields_before_bot_first_move.py | 4 +- .../_get_current_daily_challenge.py | 4 +- .../business_logic/_get_speech_bubble.py | 22 ++-- .../business_logic/_has_player_won_today.py | 4 +- .../_has_player_won_yesterday.py | 4 +- .../_manage_daily_challenge_defeat_logic.py | 4 +- ...anage_daily_challenge_moved_piece_logic.py | 6 +- .../_manage_daily_challenge_victory_logic.py | 6 +- ..._manage_new_daily_challenge_stats_logic.py | 4 +- .../_move_daily_challenge_piece.py | 12 ++- .../_restart_daily_challenge.py | 8 +- .../_see_daily_challenge_solution.py | 10 +- ..._daily_challenge_teams_and_pieces_roles.py | 22 ++-- .../business_logic/_undo_last_move.py | 8 +- .../components/misc_ui/daily_challenge_bar.py | 38 +++---- .../components/misc_ui/help.py | 6 +- .../components/misc_ui/help_modal.py | 4 +- .../components/misc_ui/stats_modal.py | 20 ++-- .../components/misc_ui/status_bar.py | 14 +-- .../components/pages/daily_chess_pages.py | 100 +++++++++--------- src/apps/daily_challenge/consts.py | 8 +- src/apps/daily_challenge/cookie_helpers.py | 16 +-- ...allenge_create_from_lichess_puzzles_csv.py | 4 +- src/apps/daily_challenge/models.py | 24 +++-- src/apps/daily_challenge/presenters.py | 46 ++++---- src/apps/daily_challenge/tests/_helpers.py | 24 +++-- ...st_manage_daily_challenge_victory_logic.py | 8 +- src/apps/daily_challenge/tests/test_models.py | 6 +- .../tests/test_server_stats.py | 22 ++-- src/apps/daily_challenge/tests/test_views.py | 44 ++++---- src/apps/daily_challenge/view_helpers.py | 16 +-- src/apps/daily_challenge/views.py | 50 +++++---- src/apps/daily_challenge/views_decorators.py | 10 +- src/apps/lichess_bridge/authentication.py | 9 +- ...ce_role_by_square_for_starting_position.py | 12 ++- .../_rebuild_game_from_moves.py | 10 +- .../business_logic/_rebuild_game_from_pgn.py | 10 +- .../components/game_creation.py | 4 +- .../components/misc_ui/user_profile_modal.py | 8 +- .../components/no_linked_account.py | 4 +- .../components/ongoing_games.py | 6 +- .../components/pages/lichess_pages.py | 80 +++++++------- src/apps/lichess_bridge/cookie_helpers.py | 16 +-- src/apps/lichess_bridge/lichess_api.py | 22 ++-- src/apps/lichess_bridge/models.py | 92 ++++++++-------- src/apps/lichess_bridge/presenters.py | 26 ++--- src/apps/lichess_bridge/tests/test_views.py | 14 +-- src/apps/lichess_bridge/views.py | 72 +++++++------ src/apps/lichess_bridge/views_decorators.py | 18 ++-- src/apps/utils/view_decorators.py | 4 +- src/apps/utils/views_helpers.py | 4 +- src/apps/webui/components/chess_units.py | 24 +++-- src/apps/webui/components/forms_common.py | 4 +- src/apps/webui/components/layout.py | 38 +++---- src/apps/webui/components/misc_ui/header.py | 4 +- .../components/misc_ui/user_prefs_modal.py | 12 ++- src/apps/webui/cookie_helpers.py | 6 +- src/apps/webui/forms.py | 2 + src/apps/webui/views.py | 4 +- src/lib/django_choices_helpers.py | 6 +- src/lib/http_cookies_helpers.py | 6 +- src/project/settings/_base.py | 1 + src/project/tests/test_alive_probe.py | 4 +- 82 files changed, 890 insertions(+), 722 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4c2b8d4..7515b85 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,18 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: + + # -------------- MyPy: type checking + # Although MyPy has limited abilities in a pre-commit context, as it can only + # type-check each file in isolation, it can still catch errors. - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.11.2 hooks: - id: mypy - additional_dependencies: [types-requests==2.31.0.2] + additional_dependencies: [ types-requests==2.31.0.2 ] exclude: "^scripts/load_testing/.*\\.py$" + + # -------------- Ruff: linter & "à la Black" formatter - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.6.3 hooks: @@ -14,8 +20,23 @@ repos: - id: ruff-format # Run the linter. - id: ruff - args: ["--fix"] + args: [ "--fix" ] exclude: "^src/project/settings/.*\\.py$" + + # -------------- pyupgrade: make sure we're using the latest Python features + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.0 + hooks: + - id: pyupgrade + args: [ "--py311-plus" ] + + # -------------- fix-future-annotations: upgrade the typing annotations syntax to PEP 585 and PEP 604. + - repo: https://github.com/frostming/fix-future-annotations + rev: 0.5.0 # a released version tag + hooks: + - id: fix-future-annotations + + # -------------- validate-pyproject: does what it says on the tin ^_^ - repo: https://github.com/abravalheri/validate-pyproject rev: v0.19 hooks: diff --git a/Makefile b/Makefile index ac12092..0981b35 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,9 @@ PYTHON_BINS ?= ./.venv/bin PYTHON ?= ${PYTHON_BINS}/python DJANGO_SETTINGS_MODULE ?= project.settings.development SUB_MAKE = ${MAKE} --no-print-directory +UV_PYTHON ?= ${PYTHON} UV ?= bin/uv +UVX ?= bin/uvx .DEFAULT_GOAL := help @@ -90,7 +92,7 @@ test: ## Launch the pytest tests suite ${PYTHON_BINS}/pytest ${pytest_opts} .PHONY: code-quality/all -code-quality/all: code-quality/ruff_check code-quality/ruff_lint code-quality/mypy ## Run all our code quality tools +code-quality/all: code-quality/ruff_check code-quality/ruff_lint code-quality/mypy code-quality/fix-future-annotations ## Run all our code quality tools .PHONY: code-quality/ruff_check code-quality/ruff_check: ruff_opts ?= @@ -110,6 +112,13 @@ code-quality/mypy: ## Python's equivalent of TypeScript # @link https://mypy.readthedocs.io/en/stable/ @${PYTHON_BINS}/mypy src/ ${mypy_opts} +.PHONY: code-quality/fix-future-annotations +code-quality/fix-future-annotations: fix_future_annotations_opts ?= +code-quality/fix-future-annotations: ## Make sure we're using PEP 585 and PEP 604 +# @link https://github.com/frostming/fix-future-annotations + @UV_PYTHON=${UV_PYTHON} \ + ${UVX} fix-future-annotations ${fix_future_annotations_opts} src/ + # Here starts the frontend stuff .PHONY: frontend/install diff --git a/pyproject.toml b/pyproject.toml index f34c9b3..3e4623d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,11 +87,12 @@ select = [ # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch "TCH001", "TCH002", - "TCH003", + "TCH003", + "FA100", # future-rewritable-type-annotation + "FA102", # future-required-type-annotation ] [tool.ruff.lint.per-file-ignores] "src/project/settings/*" = ["F405"] -"src/lib/chess_engines/sunfish/sunfish.py" = ["F841", "F821"] [tool.ruff.lint.isort] # https://docs.astral.sh/ruff/settings/#lintisort diff --git a/scripts/download_assets.py b/scripts/download_assets.py index 359e168..368c945 100755 --- a/scripts/download_assets.py +++ b/scripts/download_assets.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +from __future__ import annotations import asyncio from pathlib import Path @@ -83,7 +84,7 @@ async def download_assets(*, even_if_exists: bool) -> None: - download_coros: list["Coroutine"] = [] + download_coros: list[Coroutine] = [] limits = httpx.Limits( max_connections=DOWNLOADS_CONCURRENCY, diff --git a/src/apps/chess/business_logic/_calculate_fen_before_move.py b/src/apps/chess/business_logic/_calculate_fen_before_move.py index 69160b6..8894792 100644 --- a/src/apps/chess/business_logic/_calculate_fen_before_move.py +++ b/src/apps/chess/business_logic/_calculate_fen_before_move.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING import chess @@ -11,10 +13,10 @@ def calculate_fen_before_move( # TODO: change move_uci to a MoveTuple type *, - fen_after_move: "FEN", + fen_after_move: FEN, move_uci: str, - moving_player_side: "PlayerSide", -) -> "FEN": + moving_player_side: PlayerSide, +) -> FEN: """ Calculate the FEN of the chess board before the given move. Raises a ValueError if the move is invalid. diff --git a/src/apps/chess/business_logic/_calculate_piece_available_targets.py b/src/apps/chess/business_logic/_calculate_piece_available_targets.py index bbd7330..86c47ea 100644 --- a/src/apps/chess/business_logic/_calculate_piece_available_targets.py +++ b/src/apps/chess/business_logic/_calculate_piece_available_targets.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING import chess @@ -9,10 +11,10 @@ def calculate_piece_available_targets( - *, chess_board: chess.Board, piece_square: "Square" -) -> frozenset["Square"]: + *, chess_board: chess.Board, piece_square: Square +) -> frozenset[Square]: square_index = chess.parse_square(piece_square) - result: list["Square"] = [] + result: list[Square] = [] for move in chess_board.legal_moves: if move.from_square == square_index: result.append(chess_lib_square_to_square(move.to_square)) diff --git a/src/apps/chess/business_logic/_do_chess_move.py b/src/apps/chess/business_logic/_do_chess_move.py index 4a80f66..69dfc04 100644 --- a/src/apps/chess/business_logic/_do_chess_move.py +++ b/src/apps/chess/business_logic/_do_chess_move.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Literal, cast import chess @@ -18,12 +20,12 @@ from apps.chess.types import FEN, GameEndReason, MoveTuple, PlayerSide, Rank, Square -_CHESS_COLOR_TO_PLAYER_SIDE_MAPPING: "Mapping[chess.Color, PlayerSide]" = { +_CHESS_COLOR_TO_PLAYER_SIDE_MAPPING: Mapping[chess.Color, PlayerSide] = { True: "w", False: "b", } -_CHESS_OUTCOME_TO_GAME_END_REASON_MAPPING: "Mapping[chess.Termination, GameEndReason]" = { +_CHESS_OUTCOME_TO_GAME_END_REASON_MAPPING: Mapping[chess.Termination, GameEndReason] = { chess.Termination.CHECKMATE: "checkmate", chess.Termination.STALEMATE: "stalemate", chess.Termination.INSUFFICIENT_MATERIAL: "insufficient_material", @@ -44,7 +46,7 @@ ("e8", "c8"), ) -_CASTLING_ROOK_MOVE: "Mapping[_CastlingPossibleTo, tuple[Square, Square]]" = { +_CASTLING_ROOK_MOVE: Mapping[_CastlingPossibleTo, tuple[Square, Square]] = { # {king new square: (rook previous square, rook new square)} dict: "g1": ("h1", "f1"), "c1": ("a1", "d1"), @@ -52,7 +54,7 @@ "c8": ("a8", "d8"), } -_EN_PASSANT_CAPTURED_PIECES_RANK_CONVERSION: dict["Rank", "Rank"] = { +_EN_PASSANT_CAPTURED_PIECES_RANK_CONVERSION: dict[Rank, Rank] = { # if a pawn was captured by en passant targeting a6, its position on the board # before at the moment it's been captured was a5: "6": "5", @@ -63,9 +65,9 @@ def do_chess_move( *, - from_: "Square", - to: "Square", - fen: "FEN | None" = None, + from_: Square, + to: Square, + fen: FEN | None = None, chess_board: chess.Board | None = None, ) -> ChessMoveResult: """ @@ -77,8 +79,8 @@ def do_chess_move( "You must provide either a FEN string or a `chess.Board` object" ) - moves: list["MoveTuple"] = [] - captured: "Square | None" = None + moves: list[MoveTuple] = [] + captured: Square | None = None if not chess_board: chess_board = chess.Board(fen) diff --git a/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py index 37a3149..536109d 100644 --- a/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py +++ b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, NamedTuple, cast from ..chess_helpers import ( @@ -20,18 +22,18 @@ class ChessMoveWithPieceRoleBySquareResult(NamedTuple): - move_result: "ChessMoveResult" - piece_role_by_square: "PieceRoleBySquare" - captured_piece: "PieceRole | None" + move_result: ChessMoveResult + piece_role_by_square: PieceRoleBySquare + captured_piece: PieceRole | None def do_chess_move_with_piece_role_by_square( *, - from_: "Square", - to: "Square", - piece_role_by_square: "PieceRoleBySquare", - fen: "FEN | None" = None, - chess_board: "chess.Board | None" = None, + from_: Square, + to: Square, + piece_role_by_square: PieceRoleBySquare, + fen: FEN | None = None, + chess_board: chess.Board | None = None, ) -> ChessMoveWithPieceRoleBySquareResult: from ._do_chess_move import do_chess_move @@ -63,7 +65,7 @@ def do_chess_move_with_piece_role_by_square( ) piece_role_by_square[from_] += piece_promotion # type: ignore - captured_piece: "PieceRole | None" = None + captured_piece: PieceRole | None = None if captured := move_result["captured"]: assert move_result["is_capture"] captured_piece = piece_role_by_square[captured] diff --git a/src/apps/chess/chess_helpers.py b/src/apps/chess/chess_helpers.py index f22878e..66b75c3 100644 --- a/src/apps/chess/chess_helpers.py +++ b/src/apps/chess/chess_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cache from typing import TYPE_CHECKING, cast @@ -27,23 +29,23 @@ @cache -def chess_lib_square_to_square(chess_lib_square: int) -> "Square": +def chess_lib_square_to_square(chess_lib_square: int) -> Square: return cast("Square", chess.SQUARE_NAMES[chess_lib_square]) @cache -def chess_lib_piece_to_piece_type(chess_lib_piece: int) -> "PieceType": +def chess_lib_piece_to_piece_type(chess_lib_piece: int) -> PieceType: # a bit hacky but that will do the job for now ^^ return PIECE_INT_TO_PIECE_TYPE[chess_lib_piece] @cache -def player_side_other(player_side: "PlayerSide") -> "PlayerSide": +def player_side_other(player_side: PlayerSide) -> PlayerSide: return "w" if player_side == "b" else "b" @cache -def symbol_from_piece_role(piece_role: "PieceRole") -> "PieceSymbol": +def symbol_from_piece_role(piece_role: PieceRole) -> PieceSymbol: # If it's a promoted pawn (len == 3), we want the last character # (which is the promoted piece in such a case) return cast( @@ -52,34 +54,34 @@ def symbol_from_piece_role(piece_role: "PieceRole") -> "PieceSymbol": @cache -def type_from_piece_role(piece_role: "PieceRole") -> "PieceType": +def type_from_piece_role(piece_role: PieceRole) -> PieceType: return cast("PieceType", symbol_from_piece_role(piece_role).lower()) @cache -def type_from_piece_symbol(piece_symbol: "PieceSymbol") -> "PieceType": +def type_from_piece_symbol(piece_symbol: PieceSymbol) -> PieceType: return cast("PieceType", piece_symbol.lower()) @cache -def player_side_from_piece_symbol(piece_role: "PieceSymbol") -> "PlayerSide": +def player_side_from_piece_symbol(piece_role: PieceSymbol) -> PlayerSide: return "w" if piece_role.isupper() else "b" @cache -def player_side_from_piece_role(piece_role: "PieceRole") -> "PlayerSide": +def player_side_from_piece_role(piece_role: PieceRole) -> PlayerSide: return player_side_from_piece_symbol(piece_role) @cache -def team_member_role_from_piece_role(piece_role: "PieceRole") -> "TeamMemberRole": +def team_member_role_from_piece_role(piece_role: PieceRole) -> TeamMemberRole: return cast("TeamMemberRole", piece_role[0:2].lower()) @cache def piece_role_from_team_member_role_and_player_side( - team_member_role: "TeamMemberRole", player_side: "PlayerSide" -) -> "PieceRole": + team_member_role: TeamMemberRole, player_side: PlayerSide +) -> PieceRole: return cast( "PieceRole", team_member_role.upper() if player_side == "w" else team_member_role, @@ -87,7 +89,7 @@ def piece_role_from_team_member_role_and_player_side( @cache -def file_and_rank_from_square(square: "Square") -> tuple["File", "Rank"]: +def file_and_rank_from_square(square: Square) -> tuple[File, Rank]: file, rank = square[0], square[1] # As the result is cached, we can allow ourselves some sanity checks # when Python's "optimization mode" is requested: @@ -96,7 +98,7 @@ def file_and_rank_from_square(square: "Square") -> tuple["File", "Rank"]: @cache -def square_from_file_and_rank(file: "File", rank: "Rank") -> "Square": +def square_from_file_and_rank(file: File, rank: Rank) -> Square: """Inverse of the function above""" # ditto assert file in FILES, f"file '{file}' is not valid" @@ -105,54 +107,54 @@ def square_from_file_and_rank(file: "File", rank: "Rank") -> "Square": @cache -def piece_name_from_piece_type(piece_type: "PieceType") -> "PieceName": +def piece_name_from_piece_type(piece_type: PieceType) -> PieceName: return PIECE_TYPE_TO_NAME[piece_type] @cache -def piece_name_from_piece_role(piece_role: "PieceRole") -> "PieceName": +def piece_name_from_piece_role(piece_role: PieceRole) -> PieceName: return piece_name_from_piece_type(type_from_piece_role(piece_role)) @cache -def utf8_symbol_from_piece_type(piece_type: "PieceType") -> str: +def utf8_symbol_from_piece_type(piece_type: PieceType) -> str: return PIECE_TYPE_TO_UNICODE[piece_type] @cache -def utf8_symbol_from_piece_role(piece_role: "PieceRole") -> str: +def utf8_symbol_from_piece_role(piece_role: PieceRole) -> str: return utf8_symbol_from_piece_type(type_from_piece_role(piece_role)) -def get_squares_with_pieces_that_can_move(board: chess.Board) -> frozenset["Square"]: +def get_squares_with_pieces_that_can_move(board: chess.Board) -> frozenset[Square]: return frozenset( cast("Square", chess.square_name(move.from_square)) for move in board.legal_moves ) -def get_active_player_side_from_fen(fen: "FEN") -> "PlayerSide": +def get_active_player_side_from_fen(fen: FEN) -> PlayerSide: return cast("PlayerSide", fen.split(" ")[1]) -def get_turns_counter_from_fen(fen: "FEN") -> int: +def get_turns_counter_from_fen(fen: FEN) -> int: """Returns the fullmove number, starting from 1""" return int(fen.split(" ")[-1]) -def get_active_player_side_from_chess_board(board: chess.Board) -> "PlayerSide": +def get_active_player_side_from_chess_board(board: chess.Board) -> PlayerSide: return "w" if board.turn else "b" -def uci_move_squares(move: str) -> tuple["Square", "Square"]: +def uci_move_squares(move: str) -> tuple[Square, Square]: return cast("Square", move[:2]), cast("Square", move[2:4]) @cache -def player_side_to_chess_lib_color(player_side: "PlayerSide") -> chess.Color: +def player_side_to_chess_lib_color(player_side: PlayerSide) -> chess.Color: return chess.WHITE if player_side == "w" else chess.BLACK @cache -def chess_lib_color_to_player_side(color: chess.Color) -> "PlayerSide": +def chess_lib_color_to_player_side(color: chess.Color) -> PlayerSide: return "w" if color == chess.WHITE else "b" diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 97c701f..2eee614 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from functools import cache from string import Template @@ -46,7 +48,7 @@ INFO_BARS_COMMON_CLASSES = ( "p-2 text-slate-200 bg-slate-800 border-2 border-solid border-slate-400" ) -_PIECE_GROUND_MARKER_COLOR_TAILWIND_CLASSES: dict[tuple["PlayerSide", bool], str] = { +_PIECE_GROUND_MARKER_COLOR_TAILWIND_CLASSES: dict[tuple[PlayerSide, bool], str] = { # the boolean says if the piece can move ("w", False): "bg-emerald-800/40 border-2 border-emerald-800", ("b", False): "bg-indigo-800/40 border-2 border-indigo-800", @@ -96,8 +98,8 @@ def chess_arena( - *, game_presenter: "GamePresenter", status_bars: "list[dom_tag]", board_id: str -) -> "dom_tag": + *, game_presenter: GamePresenter, status_bars: list[dom_tag], board_id: str +) -> dom_tag: arena_additional_classes = ( "border-3 border-solid md:border-lime-400 xl:border-red-400" if settings.DEBUG_LAYOUT @@ -159,7 +161,7 @@ def chess_arena( ) -def chess_bot_data(board_id: str) -> "dom_tag": +def chess_bot_data(board_id: str) -> dom_tag: # This is used in "chess-bot.ts" match settings.JS_CHESS_ENGINE.lower(): case "lozza": @@ -181,7 +183,7 @@ def chess_bot_data(board_id: str) -> "dom_tag": ) -def chess_board(*, game_presenter: "GamePresenter", board_id: str) -> "dom_tag": +def chess_board(*, game_presenter: GamePresenter, board_id: str) -> dom_tag: force_square_info: bool = ( game_presenter.force_square_info or game_presenter.is_preview ) @@ -239,16 +241,16 @@ def chess_board(*, game_presenter: "GamePresenter", board_id: str) -> "dom_tag": def chess_pieces( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": - pieces_to_append: "list[tuple[Square, PieceRole]]" = sorted( + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: + pieces_to_append: list[tuple[Square, PieceRole]] = sorted( # We sort the pieces by their role, so that the pieces are always displayed # in the same order, regardless of their position on the chess board. game_presenter.piece_role_by_square.items(), key=lambda item: item[1], ) - pieces: "list[dom_tag]" = [] + pieces: list[dom_tag] = [] for square, piece_role in pieces_to_append: pieces.append( chess_piece( @@ -282,11 +284,11 @@ def chess_pieces( @cache def chess_board_square( - board_orientation: "BoardOrientation", - square: "Square", + board_orientation: BoardOrientation, + square: Square, *, force_square_info: bool = False, -) -> "dom_tag": +) -> dom_tag: file, rank = file_and_rank_from_square(square) square_index = FILE_NAMES.index(file) + RANK_NAMES.index(rank) square_color_cls = SQUARE_COLOR_TAILWIND_CLASSES[square_index % 2] @@ -332,11 +334,11 @@ def chess_board_square( def chess_piece( *, - game_presenter: "GamePresenter", - square: "Square", - piece_role: "PieceRole", + game_presenter: GamePresenter, + square: Square, + piece_role: PieceRole, board_id: str, -) -> "dom_tag": +) -> dom_tag: player_side = player_side_from_piece_role(piece_role) piece_can_be_moved_by_player = ( @@ -415,8 +417,8 @@ def chess_piece( def chess_available_targets( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: children: list[dom_tag] = [] if game_presenter.selected_piece and not game_presenter.is_game_over: @@ -441,11 +443,11 @@ def chess_available_targets( def chess_available_target( *, - game_presenter: "GamePresenter", - piece_player_side: "PlayerSide", - square: "Square", + game_presenter: GamePresenter, + piece_player_side: PlayerSide, + square: Square, board_id: str, -) -> "dom_tag": +) -> dom_tag: assert game_presenter.selected_piece is not None can_move = ( not game_presenter.is_game_over @@ -504,13 +506,13 @@ def chess_available_target( def chess_character_display( *, - piece_role: "PieceRole", - game_presenter: "GamePresenter | None" = None, - square: "Square | None" = None, - additional_classes: "Sequence[str]|None" = None, - factions: "GameFactions | None" = None, - board_orientation: "BoardOrientation" = "1->8", -) -> "dom_tag": + piece_role: PieceRole, + game_presenter: GamePresenter | None = None, + square: Square | None = None, + additional_classes: Sequence[str] | None = None, + factions: GameFactions | None = None, + board_orientation: BoardOrientation = "1->8", +) -> dom_tag: assert ( game_presenter or factions ), "You must provide either a GamePresenter or a Factions kwarg." @@ -557,7 +559,7 @@ def chess_character_display( if board_orientation == "1->8" else piece_player_side == "b" ) - piece_type: "PieceType" = type_from_piece_role(piece_role) + piece_type: PieceType = type_from_piece_role(piece_role) is_knight, is_king = piece_type == "n", piece_type == "k" # Right, let's do this shall we? @@ -621,8 +623,8 @@ def chess_character_display( def chess_unit_ground_marker( - *, player_side: "PlayerSide", can_move: bool = False -) -> "dom_tag": + *, player_side: PlayerSide, can_move: bool = False +) -> dom_tag: classes = [ "absolute", "w-11/12", @@ -641,10 +643,10 @@ def chess_unit_ground_marker( def chess_unit_display_with_ground_marker( *, - piece_role: "PieceRole", - game_presenter: "GamePresenter | None" = None, - factions: "GameFactions | None" = None, -) -> "dom_tag": + piece_role: PieceRole, + game_presenter: GamePresenter | None = None, + factions: GameFactions | None = None, +) -> dom_tag: assert ( game_presenter or factions ), "You must provide either a GamePresenter or a Factions kwarg." @@ -664,8 +666,8 @@ def chess_unit_display_with_ground_marker( def chess_unit_symbol_display( - *, board_orientation: "BoardOrientation", piece_role: "PieceRole" -) -> "dom_tag": + *, board_orientation: BoardOrientation, piece_role: PieceRole +) -> dom_tag: player_side = player_side_from_piece_role(piece_role) piece_type = type_from_piece_role(piece_role) piece_name = piece_name_from_piece_role(piece_role) @@ -712,8 +714,8 @@ def chess_unit_symbol_display( def chess_last_move( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: children: list[dom_tag] = [] if last_move := game_presenter.last_move: children.extend( @@ -743,10 +745,10 @@ def chess_last_move( def chess_last_move_marker( *, - board_orientation: "BoardOrientation", - square: "Square", + board_orientation: BoardOrientation, + square: Square, move_part: Literal["from", "to"], -) -> "dom_tag": +) -> dom_tag: match move_part: case "from": start_class = "!w-full" @@ -795,8 +797,8 @@ def chess_last_move_marker( def _bot_turn_html_elements( - *, game_presenter: "GamePresenter", board_id: str -) -> "list[dom_tag]": + *, game_presenter: GamePresenter, board_id: str +) -> list[dom_tag]: if ( game_presenter.solution_index is not None or not game_presenter.is_bot_turn @@ -844,8 +846,8 @@ def _bot_turn_html_elements( def _solution_turn_html_elements( - *, game_presenter: "GamePresenter", board_id: str -) -> "list[dom_tag]": + *, game_presenter: GamePresenter, board_id: str +) -> list[dom_tag]: if game_presenter.solution_index is None or game_presenter.is_game_over: return [] diff --git a/src/apps/chess/components/chess_helpers.py b/src/apps/chess/components/chess_helpers.py index 3ce52e3..fa0f207 100644 --- a/src/apps/chess/components/chess_helpers.py +++ b/src/apps/chess/components/chess_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cache from typing import TYPE_CHECKING @@ -23,9 +25,7 @@ Square, ) -_PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict[ - "BoardOrientation", dict["File", str] -] = { +_PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict[BoardOrientation, dict[File, str]] = { "1->8": { "a": "translate-y-0/1", "b": "translate-y-1/1", @@ -47,9 +47,7 @@ "h": "translate-y-0/1", }, } -_PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict[ - "BoardOrientation", dict["Rank", str] -] = { +_PIECE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict[BoardOrientation, dict[Rank, str]] = { "1->8": { "1": "translate-x-0/1", "2": "translate-x-1/1", @@ -72,7 +70,7 @@ }, } -_SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict["File", str] = { +_SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS: dict[File, str] = { "a": "top-1/8%", "b": "top-2/8%", "c": "top-3/8%", @@ -82,7 +80,7 @@ "g": "top-7/8%", "h": "top-8/8%", } -_SQUARE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict["Rank", str] = { +_SQUARE_RANK_TO_TAILWIND_POSITIONING_CLASS: dict[Rank, str] = { "1": "left-1/8%", "2": "left-2/8%", "3": "left-3/8%", @@ -93,7 +91,7 @@ "8": "left-8/8%", } -_PIECE_UNITS_CLASSES: "dict[Faction, dict[PieceName, str]]" = { +_PIECE_UNITS_CLASSES: dict[Faction, dict[PieceName, str]] = { # We need Tailwind to see these classes, so that it bundles them in the final CSS file. "humans": { "pawn": "bg-humans-pawn", @@ -113,7 +111,7 @@ }, } -_PIECE_SYMBOLS_CLASSES: "dict[PlayerSide, dict[PieceName, str]]" = { +_PIECE_SYMBOLS_CLASSES: dict[PlayerSide, dict[PieceName, str]] = { # Ditto. "w": { "pawn": "bg-w-pawn", @@ -136,8 +134,8 @@ @cache def square_to_positioning_tailwind_classes( - board_orientation: "BoardOrientation", square: "Square" -) -> "Sequence[str]": + board_orientation: BoardOrientation, square: Square +) -> Sequence[str]: file, rank = file_and_rank_from_square(square) return ( _PIECE_FILE_TO_TAILWIND_POSITIONING_CLASS[board_orientation][file], @@ -146,7 +144,7 @@ def square_to_positioning_tailwind_classes( @cache -def square_to_square_center_tailwind_classes(square: "Square") -> "Sequence[str]": +def square_to_square_center_tailwind_classes(square: Square) -> Sequence[str]: file, rank = file_and_rank_from_square(square) return ( _SQUARE_FILE_TO_TAILWIND_POSITIONING_CLASS[file], @@ -156,7 +154,7 @@ def square_to_square_center_tailwind_classes(square: "Square") -> "Sequence[str] @cache def piece_should_face_left( - board_orientation: "BoardOrientation", player_side: "PlayerSide" + board_orientation: BoardOrientation, player_side: PlayerSide ) -> bool: return (board_orientation == "1->8" and player_side == "b") or ( board_orientation == "8->1" and player_side == "w" @@ -166,10 +164,10 @@ def piece_should_face_left( @cache def piece_character_classes( *, - board_orientation: "BoardOrientation", - piece_role: "PieceRole", - factions: "GameFactions", -) -> "Sequence[str]": + board_orientation: BoardOrientation, + piece_role: PieceRole, + factions: GameFactions, +) -> Sequence[str]: player_side = player_side_from_piece_role(piece_role) piece_name = PIECE_TYPE_TO_NAME[type_from_piece_role(piece_role)] faction = factions.get_faction_for_side(player_side) @@ -182,7 +180,5 @@ def piece_character_classes( @cache -def chess_unit_symbol_class( - *, player_side: "PlayerSide", piece_name: "PieceName" -) -> str: +def chess_unit_symbol_class(*, player_side: PlayerSide, piece_name: PieceName) -> str: return _PIECE_SYMBOLS_CLASSES[player_side][piece_name] diff --git a/src/apps/chess/components/misc_ui.py b/src/apps/chess/components/misc_ui.py index 200afed..3258173 100644 --- a/src/apps/chess/components/misc_ui.py +++ b/src/apps/chess/components/misc_ui.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import random from typing import TYPE_CHECKING, Literal @@ -27,7 +29,7 @@ # TODO: manage i18n -def modal_container(*, header: "h3", body: div) -> "dom_tag": +def modal_container(*, header: h3, body: div) -> dom_tag: # Converted from https://flowbite.com/docs/components/modal/ modal_header = div( @@ -79,8 +81,8 @@ def modal_container(*, header: "h3", body: div) -> "dom_tag": def speech_bubble_container( - *, game_presenter: "GamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: GamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: if speech_bubble_data := game_presenter.speech_bubble: return speech_bubble( game_presenter=game_presenter, @@ -97,14 +99,14 @@ def speech_bubble_container( def speech_bubble( *, - game_presenter: "GamePresenter", - text: "str | dominate_text", - square: "Square", + game_presenter: GamePresenter, + text: str | dominate_text, + square: Square, time_out: float | None, - character_display: "PieceRole | None" = None, + character_display: PieceRole | None = None, board_id: str, **extra_attrs: str, -) -> "dom_tag": +) -> dom_tag: from .chess_board import chess_character_display relative_position: Literal["left", "right"] = "right" if square[1] < "5" else "left" @@ -222,5 +224,5 @@ def speech_bubble( ) -def reset_chess_engine_worker() -> "dom_tag": +def reset_chess_engine_worker() -> dom_tag: return script(raw("""window.resetChessEngineWorker()""")) diff --git a/src/apps/chess/consts.py b/src/apps/chess/consts.py index bc6f0b1..dfb5bee 100644 --- a/src/apps/chess/consts.py +++ b/src/apps/chess/consts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Final import chess @@ -13,9 +15,9 @@ Square, ) -PLAYER_SIDES: Final[tuple["PlayerSide", "PlayerSide"]] = ("w", "b") +PLAYER_SIDES: Final[tuple[PlayerSide, PlayerSide]] = ("w", "b") -PIECES_VALUES: Final[dict["PieceType", int]] = { +PIECES_VALUES: Final[dict[PieceType, int]] = { "p": 1, "n": 3, "b": 3, @@ -24,7 +26,7 @@ } # fmt: off -SQUARES: Final[tuple["Square", ...]] = ( +SQUARES: Final[tuple[Square, ...]] = ( # The order matters here, as we use that for the board visual representation. "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", @@ -36,18 +38,18 @@ "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", ) # fmt: on -FILES: Final[tuple["File", ...]] = ("a", "b", "c", "d", "e", "f", "g", "h") -RANKS: Final[tuple["Rank", ...]] = ("1", "2", "3", "4", "5", "6", "7", "8") +FILES: Final[tuple[File, ...]] = ("a", "b", "c", "d", "e", "f", "g", "h") +RANKS: Final[tuple[Rank, ...]] = ("1", "2", "3", "4", "5", "6", "7", "8") # MOVES = frozenset(f"{sq1}{sq2}" for sq1 in SQUARES for sq2 in SQUARES if sq1 != sq2) -STARTING_PIECES: dict["PlayerSide", tuple["PieceSymbol"]] = { +STARTING_PIECES: dict[PlayerSide, tuple[PieceSymbol]] = { "w": (*("P" * 8), *("N" * 2), *("B" * 2), *("R" * 2), "Q", "K"), # type: ignore "b": (*("p" * 8), *("n" * 2), *("b" * 2), *("r" * 2), "q", "k"), # type: ignore } -PIECE_INT_TO_PIECE_TYPE: dict[int, "PieceType"] = { +PIECE_INT_TO_PIECE_TYPE: dict[int, PieceType] = { chess.PAWN: "p", chess.KNIGHT: "n", chess.BISHOP: "b", @@ -56,7 +58,7 @@ chess.KING: "k", } -PIECE_TYPE_TO_NAME: dict["PieceType", "PieceName"] = { +PIECE_TYPE_TO_NAME: dict[PieceType, PieceName] = { "p": "pawn", "n": "knight", "b": "bishop", @@ -65,7 +67,7 @@ "k": "king", } -PIECE_TYPE_TO_UNICODE: dict["PieceType", str] = { +PIECE_TYPE_TO_UNICODE: dict[PieceType, str] = { "p": "♟", "n": "♞", "b": "♝", diff --git a/src/apps/chess/models.py b/src/apps/chess/models.py index af9b63b..3b2f13f 100644 --- a/src/apps/chess/models.py +++ b/src/apps/chess/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import enum from typing import TYPE_CHECKING, NamedTuple, Self @@ -68,17 +70,17 @@ def from_cookie_content(cls, cookie_content: str) -> Self: class GameFactions(NamedTuple): - w: "Faction" # the faction for the "w" player - b: "Faction" # the faction for the "b" player + w: Faction # the faction for the "w" player + b: Faction # the faction for the "b" player - def get_faction_for_side(self, item: "PlayerSide") -> "Faction": + def get_faction_for_side(self, item: PlayerSide) -> Faction: return getattr(self, item) class TeamMember(NamedTuple): - role: "TeamMemberRole" - name: "Sequence[str]" - faction: "Faction | None" = None + role: TeamMemberRole + name: Sequence[str] + faction: Faction | None = None class GameTeams(NamedTuple): @@ -86,20 +88,20 @@ class GameTeams(NamedTuple): We'll use this immutable class to store the team members for each player side. """ - w: tuple["TeamMember", ...] # the team members for the "w" player - b: tuple["TeamMember", ...] # the team members for the "b" player + w: tuple[TeamMember, ...] # the team members for the "w" player + b: tuple[TeamMember, ...] # the team members for the "b" player - def get_team_for_side(self, item: "PlayerSide") -> "tuple[TeamMember]": + def get_team_for_side(self, item: PlayerSide) -> tuple[TeamMember]: return getattr(self, item) - def to_dict(self) -> "GameTeamsDict": + def to_dict(self) -> GameTeamsDict: """ Used to store that in the database """ return {"w": list(self.w), "b": list(self.b)} @classmethod - def from_dict(cls, data: "GameTeamsDict") -> "GameTeams": + def from_dict(cls, data: GameTeamsDict) -> GameTeams: """ Used to re-hydrate the data from the database. """ diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 4f8fed0..475a0b0 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from abc import ABC, abstractmethod from functools import cached_property from typing import TYPE_CHECKING, NamedTuple, cast @@ -39,7 +41,7 @@ # Presenters are the objects we pass to our templates. -_PIECES_VALUES: dict["PieceType", int] = { +_PIECES_VALUES: dict[PieceType, int] = { "p": 1, "n": 3, "b": 3, @@ -59,18 +61,18 @@ class GamePresenter(ABC): def __init__( self, *, - fen: "FEN", - piece_role_by_square: "PieceRoleBySquare", - teams: "GameTeams", + fen: FEN, + piece_role_by_square: PieceRoleBySquare, + teams: GameTeams, refresh_last_move: bool, is_htmx_request: bool, - selected_square: "Square | None" = None, - selected_piece_square: "Square | None" = None, - target_to_confirm: "Square | None" = None, - forced_bot_move: tuple["Square", "Square"] | None = None, + selected_square: Square | None = None, + selected_piece_square: Square | None = None, + target_to_confirm: Square | None = None, + forced_bot_move: tuple[Square, Square] | None = None, force_square_info: bool = False, - last_move: tuple["Square", "Square"] | None = None, - captured_piece_role: "PieceRole | None" = None, + last_move: tuple[Square, Square] | None = None, + captured_piece_role: PieceRole | None = None, is_preview: bool = False, bot_depth: int = 1, user_prefs: UserPrefs | None = None, @@ -108,11 +110,11 @@ def __init__( @property @abstractmethod - def board_orientation(self) -> "BoardOrientation": ... + def board_orientation(self) -> BoardOrientation: ... @property @abstractmethod - def urls(self) -> "GamePresenterUrls": ... + def urls(self) -> GamePresenterUrls: ... @property @abstractmethod @@ -120,11 +122,11 @@ def is_my_turn(self) -> bool: ... @property @abstractmethod - def my_side(self) -> "PlayerSide | None": ... + def my_side(self) -> PlayerSide | None: ... @property @abstractmethod - def game_phase(self) -> "GamePhase": ... + def game_phase(self) -> GamePhase: ... # Properties derived from the chess board: @cached_property @@ -140,7 +142,7 @@ def is_game_over(self) -> bool: return self.winner is not None @cached_property - def winner(self) -> "PlayerSide | None": + def winner(self) -> PlayerSide | None: return ( None if (outcome := self._chess_board.outcome()) is None @@ -148,19 +150,19 @@ def winner(self) -> "PlayerSide | None": ) @cached_property - def active_player(self) -> "PlayerSide": + def active_player(self) -> PlayerSide: return get_active_player_side_from_chess_board(self._chess_board) @cached_property - def squares_with_pieces_that_can_move(self) -> set["Square"]: - return set( + def squares_with_pieces_that_can_move(self) -> set[Square]: + return { chess_lib_square_to_square(move.from_square) for move in self._chess_board.legal_moves - ) + } # Properties derived from the Game model: @cached_property - def active_player_side(self) -> "PlayerSide": + def active_player_side(self) -> PlayerSide: return chess_lib_color_to_player_side(self._chess_board.turn) @property @@ -181,7 +183,7 @@ def game_id(self) -> str: ... @property @abstractmethod - def factions(self) -> "GameFactions": ... + def factions(self) -> GameFactions: ... @property @abstractmethod @@ -189,17 +191,17 @@ def is_intro_turn(self) -> bool: ... @property @abstractmethod - def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": ... + def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: ... @property @abstractmethod - def speech_bubble(self) -> "SpeechBubbleData | None": ... + def speech_bubble(self) -> SpeechBubbleData | None: ... @cached_property - def piece_role_by_square(self) -> "PieceRoleBySquare": + def piece_role_by_square(self) -> PieceRoleBySquare: return self._piece_role_by_square - def piece_role_at_square(self, square: "Square") -> "PieceRole": + def piece_role_at_square(self, square: Square) -> PieceRole: try: return self._piece_role_by_square[square] except KeyError as exc: @@ -208,8 +210,8 @@ def piece_role_at_square(self, square: "Square") -> "PieceRole": @cached_property def team_members_by_role_by_side( self, - ) -> "dict[PlayerSide, dict[TeamMemberRole, TeamMember]]": - result: "dict[PlayerSide, dict[TeamMemberRole, TeamMember]]" = {} + ) -> dict[PlayerSide, dict[TeamMemberRole, TeamMember]]: + result: dict[PlayerSide, dict[TeamMemberRole, TeamMember]] = {} for player_side in PLAYER_SIDES: result[player_side] = {} for team_member in self._teams.get_team_for_side(player_side): @@ -251,11 +253,11 @@ def htmx_game_no_selection_url(self, *, board_id: str) -> str: pass @abstractmethod - def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: pass @abstractmethod - def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: pass @abstractmethod @@ -273,14 +275,14 @@ def __init__( *, game_presenter: GamePresenter, chess_board: chess.Board, - square: "Square", + square: Square, ): self._game_presenter = game_presenter self._chess_board = chess_board self.square = square @cached_property - def team_member(self) -> "TeamMember": + def team_member(self) -> TeamMember: player_side = ( self._game_presenter.selected_piece.player_side if self._game_presenter.selected_piece @@ -291,21 +293,21 @@ def team_member(self) -> "TeamMember": ] @cached_property - def player_side(self) -> "PlayerSide": + def player_side(self) -> PlayerSide: return player_side_from_piece_role( self._game_presenter.piece_role_at_square(self.square) ) @cached_property - def symbol(self) -> "PieceSymbol": + def symbol(self) -> PieceSymbol: return symbol_from_piece_role(self.piece_role) @cached_property - def piece_role(self) -> "PieceRole": + def piece_role(self) -> PieceRole: return self._game_presenter.piece_role_by_square[self.square] @cached_property - def piece_at(self) -> "chess.Piece": + def piece_at(self) -> chess.Piece: return cast("chess.Piece", self._chess_board.piece_at(self._chess_lib_square)) @cached_property @@ -325,8 +327,8 @@ def __init__( *, game_presenter: GamePresenter, chess_board: chess.Board, - piece_square: "Square", - target_to_confirm: "Square | None", + piece_square: Square, + target_to_confirm: Square | None, ): super().__init__( game_presenter=game_presenter, @@ -336,7 +338,7 @@ def __init__( self.target_to_confirm = target_to_confirm @cached_property - def available_targets(self) -> frozenset["Square"]: + def available_targets(self) -> frozenset[Square]: chess_board_active_player_side = chess_lib_color_to_player_side( self._chess_board.turn ) @@ -357,7 +359,7 @@ def available_targets(self) -> frozenset["Square"]: chess_board=chess_board, piece_square=self.square ) - def is_potential_capture(self, square: "Square") -> bool: + def is_potential_capture(self, square: Square) -> bool: return square in self.available_targets and self.piece_at is not None @cached_property @@ -375,7 +377,7 @@ def __repr__(self) -> str: class SpeechBubbleData(NamedTuple): - text: "str | text" - square: "Square" + text: str | text + square: Square time_out: float | None = None # if it's None, should be expressed in seconds - character_display: "PieceRole | None" = None + character_display: PieceRole | None = None diff --git a/src/apps/chess/tests/business_logic/test_do_chess_move.py b/src/apps/chess/tests/business_logic/test_do_chess_move.py index 1145321..914017c 100644 --- a/src/apps/chess/tests/business_logic/test_do_chess_move.py +++ b/src/apps/chess/tests/business_logic/test_do_chess_move.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING import pytest @@ -40,15 +42,13 @@ ), ) def test_can_manage_en_passant_correctly( - starting_fen: "FEN", - move: "tuple[Square, Square]", - expected_fen_after_en_passant: "FEN", - expected_moves: list["MoveTuple"], - expected_captured: "Square", + starting_fen: FEN, + move: tuple[Square, Square], + expected_fen_after_en_passant: FEN, + expected_moves: list[MoveTuple], + expected_captured: Square, ): - result: "ChessMoveResult" = do_chess_move( - fen=starting_fen, from_=move[0], to=move[1] - ) + result: ChessMoveResult = do_chess_move(fen=starting_fen, from_=move[0], to=move[1]) assert result["is_capture"] is True assert result["fen"] == expected_fen_after_en_passant diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index ca07440..131e320 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict if TYPE_CHECKING: @@ -125,17 +127,17 @@ class GameOverDescription(TypedDict): - winner: "PlayerSide | None" - reason: "GameEndReason" + winner: PlayerSide | None + reason: GameEndReason class ChessMoveResult(TypedDict): - fen: "FEN" + fen: FEN moves: list[MoveTuple] is_capture: bool captured: Square | None is_castling: bool - promotion: "PieceType | None" + promotion: PieceType | None game_over: GameOverDescription | None diff --git a/src/apps/chess/url_converters.py b/src/apps/chess/url_converters.py index 8b0fe72..0db87a9 100644 --- a/src/apps/chess/url_converters.py +++ b/src/apps/chess/url_converters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -7,8 +9,8 @@ class ChessSquareConverter: regex = "[a-h][1-8]" - def to_python(self, value: str) -> "Square": + def to_python(self, value: str) -> Square: return value # type: ignore - def to_url(self, value: "Square") -> str: + def to_url(self, value: Square) -> str: return value # type: ignore diff --git a/src/apps/daily_challenge/admin.py b/src/apps/daily_challenge/admin.py index 2d9633c..40f0a8e 100644 --- a/src/apps/daily_challenge/admin.py +++ b/src/apps/daily_challenge/admin.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import json import re from datetime import timedelta -from typing import TYPE_CHECKING, Any, Callable, Literal, TypeAlias, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast import chess from django import forms @@ -26,6 +28,8 @@ from .view_helpers import GameContext if TYPE_CHECKING: + from collections.abc import Callable + from django.db.models import QuerySet from django.http import HttpRequest @@ -51,7 +55,7 @@ _FUTURE_DAILY_CHALLENGE_COOKIE_DURATION = timedelta(minutes=20) -_INVALID_FEN_FALLBACK: "FEN" = "3k4/p7/8/8/8/8/7P/3K4 w - - 0 1" +_INVALID_FEN_FALLBACK: FEN = "3k4/p7/8/8/8/8/7P/3K4 w - - 0 1" class DailyChallengeAdminForm(forms.ModelForm): @@ -106,13 +110,13 @@ class SourceTypeListFilter(admin.SimpleListFilter): title = _("source type") parameter_name = "source_type" - def lookups(self, request: "HttpRequest", model_admin: admin.ModelAdmin): + def lookups(self, request: HttpRequest, model_admin: admin.ModelAdmin): return [ ("none", _("None")), ("lichess", _("Lichess")), ] - def queryset(self, request: "HttpRequest", queryset: "QuerySet[DailyChallenge]"): + def queryset(self, request: HttpRequest, queryset: QuerySet[DailyChallenge]): match self.value(): case "none": return queryset.filter(source__isnull=True) @@ -186,7 +190,7 @@ def get_urls(self) -> list: @staticmethod def play_future_daily_challenge_view( - request: "HttpRequest", lookup_key: str + request: HttpRequest, lookup_key: str ) -> HttpResponse: ctx = GameContext.create_from_request(request) clear_daily_challenge_game_state_in_session( @@ -205,7 +209,7 @@ def play_future_daily_challenge_view( return response @method_decorator(xframe_options_exempt) - def preview_daily_challenge_view(self, request: "HttpRequest") -> HttpResponse: + def preview_daily_challenge_view(self, request: HttpRequest) -> HttpResponse: from dominate.util import raw from apps.chess.components.chess_board import chess_arena @@ -368,7 +372,7 @@ class DailyChallengeStatsAdmin(admin.ModelAdmin): list_display_links = None view_on_site = False - def get_queryset(self, request: "HttpRequest") -> "QuerySet[DailyChallengeStats]": + def get_queryset(self, request: HttpRequest) -> QuerySet[DailyChallengeStats]: return super().get_queryset(request).select_related("challenge") def challenge_link(self, obj: DailyChallengeStats) -> str: @@ -387,24 +391,24 @@ def wins_percentage(self, obj: DailyChallengeStats) -> str: return f"{obj.wins_count/total:.1%}" if total else "-" # Stats are read-only: - def has_add_permission(self, request: "HttpRequest") -> bool: + def has_add_permission(self, request: HttpRequest) -> bool: return False def has_change_permission( - self, request: "HttpRequest", obj: DailyChallengeStats | None = None + self, request: HttpRequest, obj: DailyChallengeStats | None = None ) -> bool: return False def has_delete_permission( - self, request: "HttpRequest", obj: DailyChallengeStats | None = None + self, request: HttpRequest, obj: DailyChallengeStats | None = None ) -> bool: return False def _get_game_presenter( - fen: "FEN | None", + fen: FEN | None, bot_first_move: str | None, - intro_turn_speech_square: "Square | None", + intro_turn_speech_square: Square | None, game_update_cmd: GameUpdateCommand | None, ) -> DailyChallengeGamePresenter: from .models import PlayerGameState @@ -445,20 +449,20 @@ def _get_game_presenter( ) -def _apply_game_update(*, fen: "FEN", game_update_cmd: GameUpdateCommand) -> "FEN": +def _apply_game_update(*, fen: FEN, game_update_cmd: GameUpdateCommand) -> FEN: """Dispatches the game update command to the appropriate function.""" cmd_type, params = game_update_cmd return _GAME_UPDATE_MAPPING[cmd_type](fen=fen, **params) -def _add_piece_to_square(*, fen: "FEN", target: "Square", piece: "PieceSymbol") -> str: +def _add_piece_to_square(*, fen: FEN, target: Square, piece: PieceSymbol) -> str: chess_board = chess.Board(fen) square_int = chess.parse_square(target.lower()) chess_board.set_piece_at(square_int, chess.Piece.from_symbol(piece)) return cast("FEN", chess_board.fen()) -def _move_piece_to_square(*, fen: "FEN", from_: "Square", to: "Square") -> str: +def _move_piece_to_square(*, fen: FEN, from_: Square, to: Square) -> str: chess_board = chess.Board(fen) square_from_int = chess.parse_square(from_.lower()) square_to_int = chess.parse_square(to.lower()) @@ -468,21 +472,21 @@ def _move_piece_to_square(*, fen: "FEN", from_: "Square", to: "Square") -> str: return cast("FEN", chess_board.fen()) -def _remove_piece_from_square(*, fen: "FEN", target: "Square") -> str: +def _remove_piece_from_square(*, fen: FEN, target: Square) -> str: chess_board = chess.Board(fen) square_int = chess.parse_square(target.lower()) chess_board.remove_piece_at(square_int) return cast("FEN", chess_board.fen()) -def _mirror_board(*, fen: "FEN") -> str: +def _mirror_board(*, fen: FEN) -> str: chess_board = chess.Board(fen) chess_board.apply_mirror() chess_board.turn = chess.WHITE # it's still the human player's turn return cast("FEN", chess_board.fen()) -def _solve_problem(*, fen: "FEN") -> str: +def _solve_problem(*, fen: FEN) -> str: # This is a no-op on the server, as we solve problems on the frontend side chess_board = chess.Board(fen) return cast("FEN", chess_board.fen()) @@ -531,7 +535,7 @@ def clean_bot_first_move(self) -> str | None: raise ValidationError(exc) from exc return bot_first_move - def clean_intro_turn_speech_square(self) -> "Square | None": + def clean_intro_turn_speech_square(self) -> Square | None: intro_turn_speech_square = self.cleaned_data.get("intro_turn_speech_square", "") if not intro_turn_speech_square or len(intro_turn_speech_square) != 2: return None @@ -561,11 +565,11 @@ def clean_game_update(self) -> GameUpdateCommand | None: if TYPE_CHECKING: class CleanedData(TypedDict): - fen: "FEN" + fen: FEN bot_first_move: str | None bot_depth: int player_simulated_depth: int - intro_turn_speech_square: "Square | None" + intro_turn_speech_square: Square | None game_update: GameUpdateCommand @property diff --git a/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py b/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py index a55c9c4..0f26b36 100644 --- a/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py +++ b/src/apps/daily_challenge/business_logic/_compute_fields_before_bot_first_move.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from apps.chess.chess_helpers import uci_move_squares @@ -10,7 +12,7 @@ def compute_fields_before_bot_first_move( - challenge: "DailyChallenge", + challenge: DailyChallenge, ) -> None: """ Set the `*_before_bot_first_move` fields on the given challenge models, diff --git a/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py b/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py index 76ad896..49f1c48 100644 --- a/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py +++ b/src/apps/daily_challenge/business_logic/_get_current_daily_challenge.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING @@ -11,7 +13,7 @@ _logger = logging.getLogger("apps.daily_challenge") -def get_current_daily_challenge() -> "DailyChallenge": +def get_current_daily_challenge() -> DailyChallenge: from ..models import DailyChallenge today = timezone.now().date() diff --git a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py index b8070ca..971a8e7 100644 --- a/src/apps/daily_challenge/business_logic/_get_speech_bubble.py +++ b/src/apps/daily_challenge/business_logic/_get_speech_bubble.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import random from typing import TYPE_CHECKING @@ -37,7 +39,7 @@ def get_speech_bubble( - game_presenter: "DailyChallengeGamePresenter", + game_presenter: DailyChallengeGamePresenter, ) -> SpeechBubbleData | None: if game_presenter.game_state.solution_index is not None: return None @@ -87,11 +89,9 @@ def get_speech_bubble( team_member_role = team_member_role_from_piece_role( game_presenter.captured_piece_role ) - captured_team_member: "TeamMember" = ( - game_presenter.team_members_by_role_by_side[ - game_presenter.challenge.my_side - ][team_member_role] - ) + captured_team_member: TeamMember = game_presenter.team_members_by_role_by_side[ + game_presenter.challenge.my_side + ][team_member_role] captured_team_member_display = captured_team_member.name[0] reaction, reaction_time_out = random.choice(_UNIT_LOST_REACTIONS) return SpeechBubbleData( @@ -148,10 +148,10 @@ def get_speech_bubble( def _bot_leftmost_piece_square( - chess_board: "chess.Board", bot_side: "PlayerSide" -) -> "Square": + chess_board: chess.Board, bot_side: PlayerSide +) -> Square: leftmost_rank = 9 # *will* be overridden by our loop - leftmost_square: "Square" = "h8" # ditto + leftmost_square: Square = "h8" # ditto bot_color = player_side_to_chess_lib_color(bot_side) for square_int, piece in chess_board.piece_map().items(): if piece.color != bot_color: @@ -164,11 +164,11 @@ def _bot_leftmost_piece_square( return leftmost_square -def _my_king_square(game_presenter: "DailyChallengeGamePresenter") -> "Square": +def _my_king_square(game_presenter: DailyChallengeGamePresenter) -> Square: return _king_square(game_presenter.chess_board, game_presenter.challenge.my_side) -def _king_square(chess_board: "chess.Board", player_side: "PlayerSide") -> "Square": +def _king_square(chess_board: chess.Board, player_side: PlayerSide) -> Square: return chess_lib_square_to_square( chess_board.king(player_side_to_chess_lib_color(player_side)) ) diff --git a/src/apps/daily_challenge/business_logic/_has_player_won_today.py b/src/apps/daily_challenge/business_logic/_has_player_won_today.py index 226fd42..09ed4c8 100644 --- a/src/apps/daily_challenge/business_logic/_has_player_won_today.py +++ b/src/apps/daily_challenge/business_logic/_has_player_won_today.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.utils.timezone import now @@ -6,6 +8,6 @@ from ..models import PlayerStats -def has_player_won_today(stats: "PlayerStats") -> bool: +def has_player_won_today(stats: PlayerStats) -> bool: today = now().date() return bool((last_won := stats.last_won) and today == last_won) diff --git a/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py b/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py index 3c1eb48..68e23e1 100644 --- a/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py +++ b/src/apps/daily_challenge/business_logic/_has_player_won_yesterday.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.utils.timezone import now @@ -6,6 +8,6 @@ from ..models import PlayerStats -def has_player_won_yesterday(stats: "PlayerStats") -> bool: +def has_player_won_yesterday(stats: PlayerStats) -> bool: today = now().date() return bool((last_won := stats.last_won) and (today - last_won).days == 1) diff --git a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py index d54a869..2dc0330 100644 --- a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_defeat_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from ..models import PlayerGameOverState @@ -7,7 +9,7 @@ def manage_daily_challenge_defeat_logic( - *, game_state: "PlayerGameState", is_preview: bool = False + *, game_state: PlayerGameState, is_preview: bool = False ) -> None: """ When a player loses a daily challenge, we may need to update part of their game state. diff --git a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py index f3a399f..d3427b3 100644 --- a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_moved_piece_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.utils.timezone import now @@ -10,8 +12,8 @@ def manage_daily_challenge_moved_piece_logic( *, - game_state: "PlayerGameState", - stats: "PlayerStats", + game_state: PlayerGameState, + stats: PlayerStats, is_preview: bool = False, is_staff_user: bool = False, ) -> None: diff --git a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py index 0b56c87..8f022c1 100644 --- a/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_daily_challenge_victory_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, cast from django.utils.timezone import now @@ -10,8 +12,8 @@ def manage_daily_challenge_victory_logic( *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, stats: PlayerStats, is_preview: bool = False, is_staff_user: bool = False, diff --git a/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py b/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py index 9ecb4de..3564857 100644 --- a/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py +++ b/src/apps/daily_challenge/business_logic/_manage_new_daily_challenge_stats_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from ..models import DailyChallengeStats @@ -8,7 +10,7 @@ def manage_new_daily_challenge_stats_logic( - stats: "PlayerStats", *, is_preview: bool = False, is_staff_user: bool = False + stats: PlayerStats, *, is_preview: bool = False, is_staff_user: bool = False ) -> None: """ When a player starts a new daily challenge, diff --git a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py index 7a4dac9..a661f02 100644 --- a/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py +++ b/src/apps/daily_challenge/business_logic/_move_daily_challenge_piece.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, NamedTuple from apps.chess.business_logic import do_chess_move_with_piece_role_by_square @@ -11,15 +13,15 @@ class MoveDailyChallengePieceResult(NamedTuple): - game_state: "PlayerGameState" - captured_piece: "PieceRole | None" + game_state: PlayerGameState + captured_piece: PieceRole | None def move_daily_challenge_piece( *, - game_state: "PlayerGameState", - from_: "Square", - to: "Square", + game_state: PlayerGameState, + from_: Square, + to: Square, is_my_side: bool, ) -> MoveDailyChallengePieceResult: move_result, piece_role_by_square, captured_piece = ( diff --git a/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py b/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py index fbc5494..4b84c54 100644 --- a/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py +++ b/src/apps/daily_challenge/business_logic/_restart_daily_challenge.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy from typing import TYPE_CHECKING @@ -9,10 +11,10 @@ def restart_daily_challenge( *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, is_staff_user: bool = False, -) -> "PlayerGameState": +) -> PlayerGameState: # These fields are always set on a published challenge - let's make the # type checker happy: assert ( diff --git a/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py b/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py index 87fc697..0387a7c 100644 --- a/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py +++ b/src/apps/daily_challenge/business_logic/_see_daily_challenge_solution.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy from typing import TYPE_CHECKING @@ -10,11 +12,11 @@ def see_daily_challenge_solution( *, - challenge: "DailyChallenge", - stats: "PlayerStats", - game_state: "PlayerGameState", + challenge: DailyChallenge, + stats: PlayerStats, + game_state: PlayerGameState, is_staff_user: bool = False, -) -> "PlayerGameState": +) -> PlayerGameState: # This field is always set on a published challenge - let's make the # type checker happy: assert challenge.piece_role_by_square diff --git a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py index 7d44bef..6a507cc 100644 --- a/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py +++ b/src/apps/daily_challenge/business_logic/_set_daily_challenge_teams_and_pieces_roles.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import random from typing import TYPE_CHECKING, TypeAlias, cast @@ -22,7 +24,7 @@ TeamMemberRole, ) -_CHESS_LIB_PIECE_TYPE_TO_PIECE_TYPE_MAPPING: dict[int, "PieceType"] = { +_CHESS_LIB_PIECE_TYPE_TO_PIECE_TYPE_MAPPING: dict[int, PieceType] = { chess.PAWN: "p", chess.KNIGHT: "n", chess.BISHOP: "b", @@ -36,17 +38,17 @@ def set_daily_challenge_teams_and_pieces_roles( *, - fen: "FEN", - default_faction_w: "Faction" = "humans", - default_faction_b: "Faction" = "undeads", - bot_side: "PlayerSide" = "b", + fen: FEN, + default_faction_w: Faction = "humans", + default_faction_b: Faction = "undeads", + bot_side: PlayerSide = "b", # TODO: allow partial customisation of team members? # custom_team_members: "GameTeams | None" = None, -) -> tuple[GameTeams, "PieceRoleBySquare"]: +) -> tuple[GameTeams, PieceRoleBySquare]: chess_board = chess.Board(fen) # fmt: off - team_members_counters: dict["PlayerSide", dict["PieceType", list[int]]] = { + team_members_counters: dict[PlayerSide, dict[PieceType, list[int]]] = { # - First int of the tuple is the current counter # - Second int is the maximum value for that counter # (9 knights/bishops/rooks/queens on a player's side is quite an extreme case, @@ -60,9 +62,9 @@ def set_daily_challenge_teams_and_pieces_roles( } # fmt: on - piece_role_by_square: "PieceRoleBySquare" = {} + piece_role_by_square: PieceRoleBySquare = {} - piece_faction: dict["PlayerSide", "Faction"] = { + piece_faction: dict[PlayerSide, Faction] = { "w": default_faction_w, "b": default_faction_b, } @@ -115,7 +117,7 @@ def set_daily_challenge_teams_and_pieces_roles( ) -def _set_character_names_for_team(teams: TeamsDict, side: "PlayerSide") -> None: +def _set_character_names_for_team(teams: TeamsDict, side: PlayerSide) -> None: anonymous_team_members = teams[side] first_names = random.sample(FIRST_NAMES, k=len(anonymous_team_members)) last_names = random.sample(LAST_NAMES, k=len(anonymous_team_members)) diff --git a/src/apps/daily_challenge/business_logic/_undo_last_move.py b/src/apps/daily_challenge/business_logic/_undo_last_move.py index 90048c2..f908d13 100644 --- a/src/apps/daily_challenge/business_logic/_undo_last_move.py +++ b/src/apps/daily_challenge/business_logic/_undo_last_move.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import textwrap from typing import TYPE_CHECKING @@ -20,10 +22,10 @@ def undo_last_move( *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, is_staff_user: bool = False, -) -> "PlayerGameState": +) -> PlayerGameState: # A published challenge always has a `piece_role_by_square`: assert challenge.piece_role_by_square diff --git a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py index 015c176..ede1405 100644 --- a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools import math from typing import TYPE_CHECKING @@ -27,11 +29,11 @@ def daily_challenge_bar( *, - game_presenter: "DailyChallengeGamePresenter | None", + game_presenter: DailyChallengeGamePresenter | None, board_id: str, - inner_content: "dom_tag | None" = None, + inner_content: dom_tag | None = None, **extra_attrs: str, -) -> "dom_tag": +) -> dom_tag: from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES if not inner_content: @@ -49,7 +51,7 @@ def daily_challenge_bar( ) -def retry_confirmation_display(*, board_id: str) -> "dom_tag": +def retry_confirmation_display(*, board_id: str) -> dom_tag: htmx_attributes_confirm = { "data_hx_post": "".join( ( @@ -80,7 +82,7 @@ def retry_confirmation_display(*, board_id: str) -> "dom_tag": ) -def undo_confirmation_display(*, board_id: str) -> "dom_tag": +def undo_confirmation_display(*, board_id: str) -> dom_tag: htmx_attributes_confirm = { "data_hx_post": "".join( ( @@ -115,7 +117,7 @@ def undo_confirmation_display(*, board_id: str) -> "dom_tag": ) -def see_solution_confirmation_display(*, board_id: str) -> "dom_tag": +def see_solution_confirmation_display(*, board_id: str) -> dom_tag: htmx_attributes_confirm = { "data_hx_post": "".join( ( @@ -152,10 +154,10 @@ def see_solution_confirmation_display(*, board_id: str) -> "dom_tag": def _confirmation_dialog( *, - question: "dom_tag", + question: dom_tag, htmx_attributes_confirm: dict[str, str], htmx_attributes_cancel: dict[str, str], -) -> "dom_tag": +) -> dom_tag: return div( question, div( @@ -179,8 +181,8 @@ def _confirmation_dialog( def _current_state_display( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: if game_presenter.solution_index is not None: return _see_solution_mode_display( game_presenter=game_presenter, board_id=board_id @@ -216,8 +218,8 @@ def _current_state_display( def _undo_button( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: game_state = game_presenter.game_state can_undo: bool = game_presenter.is_preview or ( game_state.current_attempt_turns_counter > 0 @@ -257,8 +259,8 @@ def _undo_button( def _retry_button( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: can_retry: bool = game_presenter.game_state.current_attempt_turns_counter > 0 htmx_attributes = ( @@ -296,7 +298,7 @@ def _retry_button( def _see_solution_button( board_id: str, *, full_width: bool, see_it_again: bool = False -) -> "dom_tag": +) -> dom_tag: target_route = ( "daily_challenge:htmx_see_daily_challenge_solution_do" if see_it_again @@ -338,7 +340,7 @@ def _see_solution_button( ) -def _user_prefs_button(board_id: str) -> "dom_tag": +def _user_prefs_button(board_id: str) -> dom_tag: htmx_attributes = { "data_hx_get": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", @@ -370,8 +372,8 @@ def _button_classes(*, full_width: bool = True, disabled: bool = False) -> str: def _see_solution_mode_display( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str +) -> dom_tag: assert game_presenter.game_state.solution_index is not None is_game_over = game_presenter.is_game_over diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index eb28983..914b85d 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cache from typing import TYPE_CHECKING @@ -28,8 +30,8 @@ def help_content( *, challenge_solution_turns_count: int, - factions: "GameFactions", -) -> "dom_tag": + factions: GameFactions, +) -> dom_tag: spacing = "mb-3" return raw( diff --git a/src/apps/daily_challenge/components/misc_ui/help_modal.py b/src/apps/daily_challenge/components/misc_ui/help_modal.py index 0b7f3aa..a962ebc 100644 --- a/src/apps/daily_challenge/components/misc_ui/help_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/help_modal.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from dominate.tags import div, h3 @@ -14,7 +16,7 @@ # TODO: manage i18n -def help_modal(*, game_presenter: "DailyChallengeGamePresenter") -> "dom_tag": +def help_modal(*, game_presenter: DailyChallengeGamePresenter) -> dom_tag: return modal_container( header=h3( "How to play ", diff --git a/src/apps/daily_challenge/components/misc_ui/stats_modal.py b/src/apps/daily_challenge/components/misc_ui/stats_modal.py index 29cfd79..af71ab6 100644 --- a/src/apps/daily_challenge/components/misc_ui/stats_modal.py +++ b/src/apps/daily_challenge/components/misc_ui/stats_modal.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from math import ceil from typing import TYPE_CHECKING @@ -24,8 +26,8 @@ def stats_modal( - *, stats: "PlayerStats", game_state: "PlayerGameState", challenge: "DailyChallenge" -) -> "dom_tag": + *, stats: PlayerStats, game_state: PlayerGameState, challenge: DailyChallenge +) -> dom_tag: return modal_container( header=h3( "Statistics ", @@ -41,8 +43,8 @@ def stats_modal( ) -def _main_stats(stats: "PlayerStats") -> "dom_tag": - def stat(name: str, value: int) -> "dom_tag": +def _main_stats(stats: PlayerStats) -> dom_tag: + def stat(name: str, value: int) -> dom_tag: return div( div(str(value), cls="font-bold text-lg text-center"), div(name, cls="text-sm text-center"), @@ -58,8 +60,8 @@ def stat(name: str, value: int) -> "dom_tag": def _today_s_results( - *, stats: "PlayerStats", game_state: "PlayerGameState", challenge: "DailyChallenge" -) -> "dom_tag": + *, stats: PlayerStats, game_state: PlayerGameState, challenge: DailyChallenge +) -> dom_tag: if not has_player_won_today(stats): return div() # empty
@@ -98,18 +100,18 @@ def _today_s_results( ) -def _wins_distribution(stats: "PlayerStats") -> "dom_tag": +def _wins_distribution(stats: PlayerStats) -> dom_tag: max_value: int = max(stats.wins_distribution.values()) if max_value == 0: - content: "dom_tag" = div( + content: dom_tag = div( "No victories yet", cls="text-center", ) else: min_width_percentage = 8 - def row(distribution_slice: "WinsDistributionSlice", count: int) -> "dom_tag": + def row(distribution_slice: WinsDistributionSlice, count: int) -> dom_tag: slice_label = ( f"{ordinal(distribution_slice)} attempt" if distribution_slice < stats.WINS_DISTRIBUTION_SLICE_COUNT diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/misc_ui/status_bar.py index 127bf73..4bb3f99 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/status_bar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from dominate.tags import b, button, div, p @@ -26,8 +28,8 @@ def status_bar( - *, game_presenter: "DailyChallengeGamePresenter", board_id: str, **extra_attrs: str -) -> "dom_tag": + *, game_presenter: DailyChallengeGamePresenter, board_id: str, **extra_attrs: str +) -> dom_tag: from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES # TODO: split this function into smaller ones @@ -98,8 +100,8 @@ def status_bar( def _chess_status_bar_selected_piece( - game_presenter: "DailyChallengeGamePresenter", -) -> "dom_tag": + game_presenter: DailyChallengeGamePresenter, +) -> dom_tag: assert game_presenter.selected_piece is not None selected_piece = game_presenter.selected_piece @@ -141,6 +143,6 @@ def _chess_status_bar_selected_piece( def _chess_status_bar_waiting_for_bot_turn( - game_presenter: "DailyChallengeGamePresenter", -) -> "dom_tag": + game_presenter: DailyChallengeGamePresenter, +) -> dom_tag: return div("Waiting for opponent's turn 🛡", cls="w-full text-center items-center") diff --git a/src/apps/daily_challenge/components/pages/daily_chess_pages.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py index 1ea6a3b..6056bd2 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess_pages.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools from string import Template from typing import TYPE_CHECKING @@ -40,8 +42,8 @@ def daily_challenge_page( *, - game_presenter: "DailyChallengeGamePresenter", - request: "HttpRequest", + game_presenter: DailyChallengeGamePresenter, + request: HttpRequest, board_id: str, ) -> str: return page( @@ -66,62 +68,60 @@ def daily_challenge_page( def daily_challenge_moving_parts_fragment( *, - game_presenter: "DailyChallengeGamePresenter", - request: "HttpRequest", + game_presenter: DailyChallengeGamePresenter, + request: HttpRequest, board_id: str, ) -> str: return "\n".join( - ( - dom_tag.render(pretty=settings.DEBUG) - for dom_tag in ( - chess_pieces( - game_presenter=game_presenter, - board_id=board_id, - ), - chess_available_targets( - game_presenter=game_presenter, - board_id=board_id, - data_hx_swap_oob="outerHTML", - ), - ( - chess_last_move( - game_presenter=game_presenter, - board_id=board_id, - data_hx_swap_oob="outerHTML", - ) - if game_presenter.refresh_last_move - else div("") - ), - daily_challenge_bar( + dom_tag.render(pretty=settings.DEBUG) + for dom_tag in ( + chess_pieces( + game_presenter=game_presenter, + board_id=board_id, + ), + chess_available_targets( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + ( + chess_last_move( game_presenter=game_presenter, board_id=board_id, data_hx_swap_oob="outerHTML", - ), - status_bar( + ) + if game_presenter.refresh_last_move + else div("") + ), + daily_challenge_bar( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + status_bar( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + div( + speech_bubble_container( game_presenter=game_presenter, board_id=board_id, - data_hx_swap_oob="outerHTML", - ), - div( - speech_bubble_container( - game_presenter=game_presenter, - board_id=board_id, - ), - id=f"chess-speech-container-{board_id}", - data_hx_swap_oob="innerHTML", - ), - *( - [reset_chess_engine_worker()] - if game_presenter.challenge_current_attempt_turns_counter == 0 - else [] ), - *([_open_stats_modal()] if game_presenter.just_won else []), - ) + id=f"chess-speech-container-{board_id}", + data_hx_swap_oob="innerHTML", + ), + *( + [reset_chess_engine_worker()] + if game_presenter.challenge_current_attempt_turns_counter == 0 + else [] + ), + *([_open_stats_modal()] if game_presenter.just_won else []), ) ) -def _stats_button() -> "dom_tag": +def _stats_button() -> dom_tag: htmx_attributes = { "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_stats"), "data_hx_target": "#modals-container", @@ -136,7 +136,7 @@ def _stats_button() -> "dom_tag": ) -def _help_button() -> "dom_tag": +def _help_button() -> dom_tag: htmx_attributes = { "data_hx_get": reverse("daily_challenge:htmx_daily_challenge_modal_help"), "data_hx_target": "#modals-container", @@ -152,13 +152,13 @@ def _help_button() -> "dom_tag": @functools.cache -def _open_stats_modal() -> "dom_tag": +def _open_stats_modal() -> dom_tag: # We open the stats modal 2 seconds after the game is won. return _open_modal("stats", 2_000) @functools.cache -def _open_help_modal() -> "dom_tag": +def _open_help_modal() -> dom_tag: # We open the stats modal 4 seconds after the bot played their first move. return _open_modal("help", 4_000) @@ -172,7 +172,7 @@ def _open_help_modal() -> "dom_tag": ) -def _open_modal(modal_id: "Literal['stats', 'help']", delay: int) -> "dom_tag": +def _open_modal(modal_id: Literal["stats", "help"], delay: int) -> dom_tag: # TODO: use a web component for this return div( script( @@ -183,7 +183,7 @@ def _open_modal(modal_id: "Literal['stats', 'help']", delay: int) -> "dom_tag": ) -def _open_graph_meta_tags() -> "tuple[dom_tag, ...]": +def _open_graph_meta_tags() -> tuple[dom_tag, ...]: return ( meta( property="og:image", diff --git a/src/apps/daily_challenge/consts.py b/src/apps/daily_challenge/consts.py index 5df996b..20f9e78 100644 --- a/src/apps/daily_challenge/consts.py +++ b/src/apps/daily_challenge/consts.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Final from apps.chess.models import GameFactions @@ -5,8 +7,8 @@ if TYPE_CHECKING: from apps.chess.types import PlayerSide -PLAYER_SIDE: "Final[PlayerSide]" = "w" -BOT_SIDE: "Final[PlayerSide]" = "b" -FACTIONS: "Final[GameFactions]" = GameFactions( +PLAYER_SIDE: Final[PlayerSide] = "w" +BOT_SIDE: Final[PlayerSide] = "b" +FACTIONS: Final[GameFactions] = GameFactions( w="humans", b="undeads" ) # hard-coded for now diff --git a/src/apps/daily_challenge/cookie_helpers.py b/src/apps/daily_challenge/cookie_helpers.py index ade62d6..3f36d39 100644 --- a/src/apps/daily_challenge/cookie_helpers.py +++ b/src/apps/daily_challenge/cookie_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import TYPE_CHECKING, NamedTuple @@ -25,7 +27,7 @@ class DailyChallengeStateForPlayer(NamedTuple): def get_or_create_daily_challenge_state_for_player( - *, request: "HttpRequest", challenge: "DailyChallenge" + *, request: HttpRequest, challenge: DailyChallenge ) -> DailyChallengeStateForPlayer: """ Returns the game state for the given challenge, creating it if it doesn't exist yet. @@ -69,7 +71,7 @@ def get_or_create_daily_challenge_state_for_player( def get_player_session_content_from_request( - request: "HttpRequest", + request: HttpRequest, ) -> PlayerSessionContent: def new_content(): return PlayerSessionContent(games={}, stats=PlayerStats()) @@ -92,7 +94,7 @@ def new_content(): def save_daily_challenge_state_in_session( - *, request: "HttpRequest", game_state: PlayerGameState, player_stats: PlayerStats + *, request: HttpRequest, game_state: PlayerGameState, player_stats: PlayerStats ) -> None: # Erases other games data! challenge_id = today_daily_challenge_id(request) @@ -103,7 +105,7 @@ def save_daily_challenge_state_in_session( def clear_daily_challenge_game_state_in_session( - *, request: "HttpRequest", player_stats: PlayerStats + *, request: HttpRequest, player_stats: PlayerStats ) -> None: # Erases current games data! session_content = PlayerSessionContent(games={}, stats=player_stats) @@ -111,7 +113,7 @@ def clear_daily_challenge_game_state_in_session( def clear_daily_challenge_stats_in_session( - *, request: "HttpRequest", game_state: PlayerGameState + *, request: HttpRequest, game_state: PlayerGameState ) -> None: # Erases all-time stats data! challenge_id = today_daily_challenge_id(request) @@ -121,7 +123,7 @@ def clear_daily_challenge_stats_in_session( _store_player_session_content(request, session_content) -def today_daily_challenge_id(request: "HttpRequest") -> str: +def today_daily_challenge_id(request: HttpRequest) -> str: if request.user.is_staff: admin_daily_challenge_lookup_key = request.get_signed_cookie( "admin_daily_challenge_lookup_key", default=None @@ -132,7 +134,7 @@ def today_daily_challenge_id(request: "HttpRequest") -> str: def _store_player_session_content( - request: "HttpRequest", session_content: PlayerSessionContent + request: HttpRequest, session_content: PlayerSessionContent ) -> None: cookie_content = session_content.to_cookie_content() request.session[_PLAYER_CONTENT_SESSION_KEY] = cookie_content diff --git a/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py b/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py index 4e9d15e..baf69e5 100644 --- a/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py +++ b/src/apps/daily_challenge/management/commands/dailychallenge_create_from_lichess_puzzles_csv.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import csv import re from pathlib import Path @@ -134,7 +136,7 @@ def handle( def get_bot_first_move_and_resulting_fen( csv_row: dict, -) -> "BotFirstMoveAndResultingFen": +) -> BotFirstMoveAndResultingFen: fen_before_bot_first_move = csv_row["FEN"] bot_first_move_uci = csv_row["Moves"][0:4] diff --git a/src/apps/daily_challenge/models.py b/src/apps/daily_challenge/models.py index ce9e2c3..afe5657 100644 --- a/src/apps/daily_challenge/models.py +++ b/src/apps/daily_challenge/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime as dt import enum import math @@ -70,13 +72,13 @@ class DailyChallenge(models.Model): status: DailyChallengeStatus = models.IntegerField( choices=DailyChallengeStatus.choices, default=DailyChallengeStatus.PENDING ) - created_at: "dt.datetime" = models.DateTimeField(auto_now_add=True) - updated_at: "dt.datetime" = models.DateTimeField(auto_now=True) + created_at: dt.datetime = models.DateTimeField(auto_now_add=True) + updated_at: dt.datetime = models.DateTimeField(auto_now=True) # --- # The following 2 fields carry the state of the game we want # the daily challenge to start with... - fen: "FEN" = models.CharField(max_length=_FEN_MAX_LEN) - piece_role_by_square: "PieceRoleBySquare|None" = models.JSONField( + fen: FEN = models.CharField(max_length=_FEN_MAX_LEN) + piece_role_by_square: PieceRoleBySquare | None = models.JSONField( null=True, editable=False ) # --- @@ -94,7 +96,7 @@ class DailyChallenge(models.Model): default=5, help_text="The depth of the player's simulated search. 5 is a good value for modeling a 'casual' chess player (like myself ^_^).", ) - intro_turn_speech_square: "Square|None" = models.CharField(null=True, max_length=2) + intro_turn_speech_square: Square | None = models.CharField(null=True, max_length=2) starting_advantage: int | None = models.IntegerField( null=True, help_text="positive number means the human player has an advantage, " @@ -112,13 +114,13 @@ class DailyChallenge(models.Model): # Fields that are inferred from the above fields: # We want the bot to play first, in a deterministic way, # so we also need to store the state of the game before that first move. - fen_before_bot_first_move: "FEN | None" = models.CharField( + fen_before_bot_first_move: FEN | None = models.CharField( max_length=_FEN_MAX_LEN, null=True, editable=False ) - piece_role_by_square_before_bot_first_move: "PieceRoleBySquare | None" = ( + piece_role_by_square_before_bot_first_move: PieceRoleBySquare | None = ( models.JSONField(null=True, editable=False) ) - teams: "GameTeamsDict | None" = models.JSONField(null=True, editable=False) + teams: GameTeamsDict | None = models.JSONField(null=True, editable=False) intro_turn_speech_text: str = models.CharField(max_length=100, blank=True) solution_turns_count: int = models.PositiveSmallIntegerField( null=True, editable=False @@ -136,7 +138,7 @@ def bot_side(self) -> PlayerSide: return BOT_SIDE @property - def factions(self) -> "GameFactions": + def factions(self) -> GameFactions: return FACTIONS def clean(self) -> None: @@ -266,7 +268,7 @@ def _increment_counter(self, field_name: str) -> None: self.filter(day=self._today()).update(**{field_name: F(field_name) + 1}) @staticmethod - def _today() -> "dt.date": + def _today() -> dt.date: return now().date() @@ -359,7 +361,7 @@ class PlayerGameState( # These are the moves *of the current attempt* only. moves: str undo_used: bool = False - game_over: "PlayerGameOverState" = PlayerGameOverState.PLAYING + game_over: PlayerGameOverState = PlayerGameOverState.PLAYING victory_turns_count: int | None = None # is a half-move index when the player gave up to see the solution: solution_index: int | None = None diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 22474bc..7f3ba0e 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cached_property from typing import TYPE_CHECKING from urllib.parse import urlencode @@ -32,21 +34,21 @@ class DailyChallengeGamePresenter(GamePresenter): def __init__( self, *, - challenge: "DailyChallenge", - game_state: "PlayerGameState", + challenge: DailyChallenge, + game_state: PlayerGameState, refresh_last_move: bool, is_htmx_request: bool, - forced_bot_move: tuple["Square", "Square"] | None = None, - forced_speech_bubble: tuple["Square", str] | None = None, - selected_piece_square: "Square | None" = None, - target_to_confirm: "Square | None" = None, + forced_bot_move: tuple[Square, Square] | None = None, + forced_speech_bubble: tuple[Square, str] | None = None, + selected_piece_square: Square | None = None, + target_to_confirm: Square | None = None, is_bot_move: bool = False, force_square_info: bool = False, - captured_team_member_role: "PieceRole | None" = None, + captured_team_member_role: PieceRole | None = None, just_won: bool = False, is_preview: bool = False, is_very_first_game: bool = False, - user_prefs: "UserPrefs | None" = None, + user_prefs: UserPrefs | None = None, ): # A published challenge always has a `teams` non-null field: assert challenge.teams @@ -75,11 +77,11 @@ def __init__( self._forced_speech_bubble = forced_speech_bubble @cached_property - def board_orientation(self) -> "BoardOrientation": + def board_orientation(self) -> BoardOrientation: return "1->8" if self._challenge.my_side == "w" else "8->1" @cached_property - def urls(self) -> "DailyChallengeGamePresenterUrls": + def urls(self) -> DailyChallengeGamePresenterUrls: return DailyChallengeGamePresenterUrls(game_presenter=self) @cached_property @@ -87,7 +89,7 @@ def is_my_turn(self) -> bool: return not self.is_bot_turn @cached_property - def my_side(self) -> "PlayerSide | None": + def my_side(self) -> PlayerSide | None: return self._challenge.my_side @cached_property @@ -107,7 +109,7 @@ def challenge_attempts_counter(self) -> int: return self.game_state.attempts_counter @cached_property - def game_phase(self) -> "GamePhase": + def game_phase(self) -> GamePhase: if (winner := self.winner) is not None: return ( "game_over:won" @@ -144,7 +146,7 @@ def game_id(self) -> str: return str(self._challenge.id) @cached_property - def factions(self) -> "GameFactions": + def factions(self) -> GameFactions: return self._challenge.factions @cached_property @@ -160,31 +162,31 @@ def is_player_move(self) -> bool: return not self.is_bot_move @cached_property - def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": + def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: if self.is_intro_turn: return self._challenge.my_side return None @cached_property - def speech_bubble(self) -> "SpeechBubbleData | None": + def speech_bubble(self) -> SpeechBubbleData | None: return get_speech_bubble(self) @property - def chess_board(self) -> "chess.Board": + def chess_board(self) -> chess.Board: return self._chess_board @property - def challenge(self) -> "DailyChallenge": + def challenge(self) -> DailyChallenge: return self._challenge @property - def forced_speech_bubble(self) -> tuple["Square", str] | None: + def forced_speech_bubble(self) -> tuple[Square, str] | None: return self._forced_speech_bubble @staticmethod def _last_move_from_game_state( - game_state: "PlayerGameState", - ) -> tuple["Square", "Square"] | None: + game_state: PlayerGameState, + ) -> tuple[Square, Square] | None: if (moves := game_state.moves) and len(moves) >= 4: return uci_move_squares(moves[-4:]) return None @@ -200,7 +202,7 @@ def htmx_game_no_selection_url(self, *, board_id: str) -> str: ) ) - def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: return "".join( ( reverse( @@ -214,7 +216,7 @@ def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: ) ) - def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: assert self._game_presenter.selected_piece is not None # type checker: happy return "".join( ( diff --git a/src/apps/daily_challenge/tests/_helpers.py b/src/apps/daily_challenge/tests/_helpers.py index 8705857..a757011 100644 --- a/src/apps/daily_challenge/tests/_helpers.py +++ b/src/apps/daily_challenge/tests/_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re from http import HTTPStatus from typing import TYPE_CHECKING, Literal @@ -19,7 +21,7 @@ ) -def assert_response_waiting_for_bot_move(response: "HttpResponse") -> None: +def assert_response_waiting_for_bot_move(response: HttpResponse) -> None: response_html = response.content.decode() assert_response_contains_a_bot_move_to_play(response_html) assert_response_does_not_contain_pieces_selection(response_html) @@ -35,11 +37,11 @@ def assert_response_does_not_contain_pieces_selection(response_content: str) -> def play_player_move( - client: "DjangoClient", - move: "str | MoveTuple", + client: DjangoClient, + move: str | MoveTuple, *, expected_status_code: HTTPStatus = HTTPStatus.OK, -) -> "HttpResponse": +) -> HttpResponse: if isinstance(move, str): move = uci_move_squares(move) assert isinstance(move, tuple) and len(move) == 2 @@ -52,11 +54,11 @@ def play_player_move( def play_bot_move( - client: "DjangoClient", - move: "str | MoveTuple", + client: DjangoClient, + move: str | MoveTuple, *, expected_status_code: HTTPStatus = HTTPStatus.OK, -) -> "HttpResponse": +) -> HttpResponse: if isinstance(move, str): move = uci_move_squares(move) assert isinstance(move, tuple) and len(move) == 2 @@ -68,7 +70,7 @@ def play_bot_move( return response -def start_new_attempt(client: "DjangoClient") -> None: +def start_new_attempt(client: DjangoClient) -> None: restarts_count = get_today_server_stats().restarts_count response = client.post("/htmx/daily-challenge/restart/do/") assert response.status_code == HTTPStatus.OK @@ -76,8 +78,8 @@ def start_new_attempt(client: "DjangoClient") -> None: def play_moves( - client: "DjangoClient", - moves: list["MoveTuple"], + client: DjangoClient, + moves: list[MoveTuple], starting_side=Literal["bot", "player"], ) -> None: current_side = starting_side @@ -91,6 +93,6 @@ def get_today_server_stats() -> DailyChallengeStats: return DailyChallengeStats.objects.get(day=now().date()) -def get_session_content(client: "DjangoClient") -> PlayerSessionContent: +def get_session_content(client: DjangoClient) -> PlayerSessionContent: session_cookie_content: str = client.session.get("pc") return PlayerSessionContent.from_cookie_content(session_cookie_content) diff --git a/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py b/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py index 0d735ed..0794838 100644 --- a/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py +++ b/src/apps/daily_challenge/tests/business_logic/test_manage_daily_challenge_victory_logic.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime as dt from typing import TYPE_CHECKING from unittest import mock @@ -30,7 +32,7 @@ def dummy_daily_challenge(): def test_manage_daily_challenge_victory_wins_count( # Test dependencies dummy_daily_challenge, - player_game_state_minimalist: "PlayerGameState", + player_game_state_minimalist: PlayerGameState, ): game_state = player_game_state_minimalist game_state.turns_counter = 8 @@ -72,7 +74,7 @@ def test_manage_daily_challenge_victory_wins_count( def test_manage_daily_challenge_victory_logic_wins_distribution( # Test dependencies dummy_daily_challenge, - player_game_state_minimalist: "PlayerGameState", + player_game_state_minimalist: PlayerGameState, # Test parameters attempts_counter: int, expected_wins_distribution: list[int], @@ -115,7 +117,7 @@ def test_manage_daily_challenge_victory_logic_wins_distribution( def test_manage_daily_challenge_victory_logic_streak_management( # Test dependencies dummy_daily_challenge, - player_game_state_minimalist: "PlayerGameState", + player_game_state_minimalist: PlayerGameState, # Test parameters current_streak: int, max_streak: int, diff --git a/src/apps/daily_challenge/tests/test_models.py b/src/apps/daily_challenge/tests/test_models.py index 7a0f799..9691360 100644 --- a/src/apps/daily_challenge/tests/test_models.py +++ b/src/apps/daily_challenge/tests/test_models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from contextlib import nullcontext as noraise from typing import TYPE_CHECKING @@ -25,9 +27,9 @@ ], ) def test_solution_turns_counter_computation( - challenge_minimalist: "DailyChallenge", + challenge_minimalist: DailyChallenge, solution: str, - context: "AbstractContextManager", + context: AbstractContextManager, expected_moves_count: int | None, ): assert challenge_minimalist.solution_turns_count == 1 diff --git a/src/apps/daily_challenge/tests/test_server_stats.py b/src/apps/daily_challenge/tests/test_server_stats.py index fa2eb4c..dea5615 100644 --- a/src/apps/daily_challenge/tests/test_server_stats.py +++ b/src/apps/daily_challenge/tests/test_server_stats.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from unittest import mock @@ -25,8 +27,8 @@ def test_server_stats_played_challenges_count( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, cleared_django_default_cache, ): # TODO: simplify this test? 😅 @@ -40,16 +42,16 @@ def play_first_2_turns(expected_played_challenges_count: int) -> None: assert sut() == expected_played_challenges_count # player 1st move: - player_move_1: "MoveTuple" = ("a1", "b1") + player_move_1: MoveTuple = ("a1", "b1") play_player_move(client, player_move_1) assert sut() == expected_played_challenges_count - bot_move: "MoveTuple" = ("a7", "a6") + bot_move: MoveTuple = ("a7", "a6") play_bot_move(client, bot_move) assert sut() == expected_played_challenges_count # player 2nd move: - player_move_2: "MoveTuple" = ("b1", "a1") + player_move_2: MoveTuple = ("b1", "a1") play_player_move(client, player_move_2) def play_day_session(): @@ -116,8 +118,8 @@ def test_server_stats_returning_players_count( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, cleared_django_default_cache, # Test parameters previous_game_date: str | None, @@ -141,16 +143,16 @@ def sut() -> int: assert sut() == 0 # player 1st move: - player_move_1: "MoveTuple" = ("a1", "b1") + player_move_1: MoveTuple = ("a1", "b1") play_player_move(client, player_move_1) assert sut() == 0 - bot_move: "MoveTuple" = ("a7", "a6") + bot_move: MoveTuple = ("a7", "a6") play_bot_move(client, bot_move) assert sut() == 0 # player 2nd move: # --> that's where we keep a record of whether it's a returning player or not - player_move_2: "MoveTuple" = ("b1", "a1") + player_move_2: MoveTuple = ("b1", "a1") play_player_move(client, player_move_2) assert sut() == expected_returning_players_count diff --git a/src/apps/daily_challenge/tests/test_views.py b/src/apps/daily_challenge/tests/test_views.py index 7096d12..4477142 100644 --- a/src/apps/daily_challenge/tests/test_views.py +++ b/src/apps/daily_challenge/tests/test_views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from http import HTTPStatus from typing import TYPE_CHECKING from unittest import mock @@ -35,8 +37,8 @@ def test_game_smoke_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -108,8 +110,8 @@ def test_htmx_game_select_piece_input_validation( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters location: str, expected_status_code: int, @@ -140,10 +142,10 @@ def test_htmx_game_select_piece_returned_html( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters - square: "Square", + square: Square, expected_team_member_name_display: str, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -184,8 +186,8 @@ def test_htmx_game_move_piece_input_validation( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters input_: dict, expected_status_code: HTTPStatus, @@ -230,8 +232,8 @@ def test_htmx_game_play_bot_move_validation( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, # Test parameters input_: dict, expected_status_code: int, @@ -251,8 +253,8 @@ def test_htmx_game_select_piece_should_fail_on_empty_square( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -271,8 +273,8 @@ def test_stats_modal_smoke_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -289,8 +291,8 @@ def test_stats_modal_can_display_todays_victory_metrics_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, cleared_django_default_cache, ): get_current_challenge_mock.return_value = challenge_minimalist @@ -311,12 +313,12 @@ def _open_stats_modal() -> str: # Now let's win the game in 2 attempts, and re-open that modal: # 1st attempt: - attempt_1_moves: "list[MoveTuple]" = [("b8", "a8"), ("h2", "g1"), ("a8", "b8")] + attempt_1_moves: list[MoveTuple] = [("b8", "a8"), ("h2", "g1"), ("a8", "b8")] play_moves(client, attempt_1_moves, starting_side="bot") start_new_attempt(client) # 2nd attempt, ends with a mate: # fmt:off - attempt_2_moves:"list[MoveTuple]" = [("b8", "a8"), ("h2", "g1"), ("a8", "b8"), ("g1", "h2"), ("b8", "a8"), ("f7", "f8")] + attempt_2_moves:list[MoveTuple] = [("b8", "a8"), ("h2", "g1"), ("a8", "b8"), ("g1", "h2"), ("b8", "a8"), ("f7", "f8")] # fmt:on play_moves(client, attempt_2_moves, starting_side="bot") @@ -335,8 +337,8 @@ def test_help_modal_smoke_test( # Mocks get_current_challenge_mock: mock.MagicMock, # Test dependencies - challenge_minimalist: "DailyChallenge", - client: "DjangoClient", + challenge_minimalist: DailyChallenge, + client: DjangoClient, ): get_current_challenge_mock.return_value = challenge_minimalist diff --git a/src/apps/daily_challenge/view_helpers.py b/src/apps/daily_challenge/view_helpers.py index 6f37d40..656b28c 100644 --- a/src/apps/daily_challenge/view_helpers.py +++ b/src/apps/daily_challenge/view_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import dataclasses from typing import TYPE_CHECKING, cast @@ -21,20 +23,20 @@ class GameContext: and some other data that is useful for our Views (aka "Controllers" in MVC). """ - challenge: "DailyChallenge" + challenge: DailyChallenge is_preview: bool is_staff_user: bool """`is_preview` is True if we're in admin preview mode""" - game_state: "PlayerGameState" - stats: "PlayerStats" - user_prefs: "UserPrefs" + game_state: PlayerGameState + stats: PlayerStats + user_prefs: UserPrefs created: bool """if the game state was created on the fly as we were initialising that object""" board_id: str = "main" @classmethod - def create_from_request(cls, request: "HttpRequest") -> "GameContext": + def create_from_request(cls, request: HttpRequest) -> GameContext: is_staff_user: bool = request.user.is_staff challenge, is_preview = get_current_daily_challenge_or_admin_preview(request) game_state, stats, created = ( @@ -64,8 +66,8 @@ def create_from_request(cls, request: "HttpRequest") -> "GameContext": def get_current_daily_challenge_or_admin_preview( - request: "HttpRequest", -) -> tuple["DailyChallenge", bool]: + request: HttpRequest, +) -> tuple[DailyChallenge, bool]: from .business_logic import get_current_daily_challenge from .models import DailyChallenge diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index 6123df4..7ac960e 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools import logging from typing import TYPE_CHECKING @@ -54,7 +56,7 @@ @require_safe @with_game_context -def game_view(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: +def game_view(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: if ctx.created: # The player hasn't played this challenge before, # so we need to start from the beginning, with the bot's first move: @@ -106,9 +108,7 @@ def game_view(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: @require_safe @with_game_context @redirect_if_game_not_started -def htmx_game_no_selection( - request: "HttpRequest", *, ctx: "GameContext" -) -> HttpResponse: +def htmx_game_no_selection(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: game_presenter = DailyChallengeGamePresenter( challenge=ctx.challenge, game_state=ctx.game_state, @@ -127,7 +127,7 @@ def htmx_game_no_selection( @with_game_context @redirect_if_game_not_started def htmx_game_select_piece( - request: "HttpRequest", *, ctx: "GameContext", location: "Square" + request: HttpRequest, *, ctx: GameContext, location: Square ) -> HttpResponse: game_presenter = DailyChallengeGamePresenter( challenge=ctx.challenge, @@ -148,7 +148,7 @@ def htmx_game_select_piece( @with_game_context @redirect_if_game_not_started def htmx_game_move_piece( - request: "HttpRequest", *, ctx: "GameContext", from_: "Square", to: "Square" + request: HttpRequest, *, ctx: GameContext, from_: Square, to: Square ) -> HttpResponse: if from_ == to: raise ChessInvalidMoveException("Not a move") @@ -223,7 +223,7 @@ def htmx_game_move_piece( @require_safe @with_game_context def htmx_daily_challenge_stats_modal( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: modal_content = stats_modal( stats=ctx.stats, game_state=ctx.game_state, challenge=ctx.challenge @@ -235,7 +235,7 @@ def htmx_daily_challenge_stats_modal( @require_safe @with_game_context def htmx_daily_challenge_help_modal( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: game_presenter = DailyChallengeGamePresenter( challenge=ctx.challenge, @@ -254,7 +254,7 @@ def htmx_daily_challenge_help_modal( @with_game_context @redirect_if_game_not_started def htmx_restart_daily_challenge_ask_confirmation( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: from .components.misc_ui.daily_challenge_bar import ( daily_challenge_bar, @@ -278,7 +278,7 @@ def htmx_restart_daily_challenge_ask_confirmation( @with_game_context @redirect_if_game_not_started def htmx_restart_daily_challenge_do( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: # This field is always set on a published challenge: assert ctx.challenge.bot_first_move @@ -315,7 +315,7 @@ def htmx_restart_daily_challenge_do( @with_game_context @redirect_if_game_not_started def htmx_undo_last_move_ask_confirmation( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: from .components.misc_ui.daily_challenge_bar import ( daily_challenge_bar, @@ -336,9 +336,7 @@ def htmx_undo_last_move_ask_confirmation( @require_POST @with_game_context @redirect_if_game_not_started -def htmx_undo_last_move_do( - request: "HttpRequest", *, ctx: "GameContext" -) -> HttpResponse: +def htmx_undo_last_move_do(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: new_game_state = undo_last_move( challenge=ctx.challenge, game_state=ctx.game_state, @@ -368,7 +366,7 @@ def htmx_undo_last_move_do( @with_game_context @redirect_if_game_not_started def htmx_see_daily_challenge_solution_ask_confirmation( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: from .components.misc_ui.daily_challenge_bar import ( daily_challenge_bar, @@ -392,7 +390,7 @@ def htmx_see_daily_challenge_solution_ask_confirmation( @with_game_context @redirect_if_game_not_started def htmx_see_daily_challenge_solution_do( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: new_game_state = see_daily_challenge_solution( challenge=ctx.challenge, @@ -427,7 +425,7 @@ def htmx_see_daily_challenge_solution_do( @with_game_context @redirect_if_game_not_started def htmx_see_daily_challenge_solution_play( - request: "HttpRequest", *, ctx: "GameContext" + request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: if (solution_index := ctx.game_state.solution_index) is None: # This is a fishy request 😅 @@ -474,7 +472,7 @@ def htmx_see_daily_challenge_solution_play( @with_game_context @redirect_if_game_not_started def htmx_game_bot_move( - request: "HttpRequest", *, ctx: "GameContext", from_: "Square", to: "Square" + request: HttpRequest, *, ctx: GameContext, from_: Square, to: Square ) -> HttpResponse: if from_ == to: raise ChessInvalidMoveException("Not a move") @@ -498,7 +496,7 @@ def htmx_game_bot_move( @require_safe @user_passes_test(user_is_staff) @with_game_context -def debug_reset_today(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: +def debug_reset_today(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: clear_daily_challenge_game_state_in_session(request=request, player_stats=ctx.stats) return redirect("daily_challenge:daily_game_view") @@ -507,7 +505,7 @@ def debug_reset_today(request: "HttpRequest", *, ctx: "GameContext") -> HttpResp @require_safe @user_passes_test(user_is_staff) @with_game_context -def debug_reset_stats(request: "HttpRequest", *, ctx: "GameContext") -> HttpResponse: +def debug_reset_stats(request: HttpRequest, *, ctx: GameContext) -> HttpResponse: # This function is VERY dangerous, so let's make sure we're not using it # in another view accidentally 😅 from .cookie_helpers import clear_daily_challenge_stats_in_session @@ -519,7 +517,7 @@ def debug_reset_stats(request: "HttpRequest", *, ctx: "GameContext") -> HttpResp @require_safe @user_passes_test(user_is_staff) -def debug_view_cookie(request: "HttpRequest") -> HttpResponse: +def debug_view_cookie(request: HttpRequest) -> HttpResponse: import msgspec from .cookie_helpers import get_player_session_content_from_request @@ -548,9 +546,9 @@ def format_struct(struct): def _play_bot_move( *, - request: "HttpRequest", - ctx: "GameContext", - move: "MoveTuple", + request: HttpRequest, + ctx: GameContext, + move: MoveTuple, board_id: str, ) -> HttpResponse: game_over_already = ctx.game_state.game_over != PlayerGameOverState.PLAYING @@ -595,7 +593,7 @@ def _play_bot_move( def _daily_challenge_moving_parts_fragment_response( *, game_presenter: DailyChallengeGamePresenter, - request: "HttpRequest", + request: HttpRequest, board_id: str, ) -> HttpResponse: return HttpResponse( @@ -608,5 +606,5 @@ def _daily_challenge_moving_parts_fragment_response( @functools.lru_cache(maxsize=20) def _daily_challenge_move_for_solution_index( challenge_solution: str, solution_index: int -) -> tuple["Square", "Square"]: +) -> tuple[Square, Square]: return uci_move_squares(challenge_solution.split(",")[solution_index]) diff --git a/src/apps/daily_challenge/views_decorators.py b/src/apps/daily_challenge/views_decorators.py index 2d54b2f..76069c8 100644 --- a/src/apps/daily_challenge/views_decorators.py +++ b/src/apps/daily_challenge/views_decorators.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools from typing import TYPE_CHECKING @@ -28,7 +30,7 @@ def wrapper(*args, **kwargs): def with_game_context(func): @functools.wraps(func) - def wrapper(request: "HttpRequest", *args, **kwargs): + def wrapper(request: HttpRequest, *args, **kwargs): ctx = GameContext.create_from_request(request) return func(request, *args, ctx=ctx, **kwargs) @@ -37,7 +39,7 @@ def wrapper(request: "HttpRequest", *args, **kwargs): def redirect_if_game_not_started(func): @functools.wraps(func) - def wrapper(request: "HttpRequest", *args, ctx: GameContext, **kwargs): + def wrapper(request: HttpRequest, *args, ctx: GameContext, **kwargs): if ctx.created: return _redirect_to_game_view_screen_with_brand_new_game(request, ctx.stats) return func(request, *args, ctx=ctx, **kwargs) @@ -46,8 +48,8 @@ def wrapper(request: "HttpRequest", *args, ctx: GameContext, **kwargs): def _redirect_to_game_view_screen_with_brand_new_game( - request: "HttpRequest", player_stats: "PlayerStats" -) -> "HttpResponse": + request: HttpRequest, player_stats: PlayerStats +) -> HttpResponse: clear_daily_challenge_game_state_in_session( request=request, player_stats=player_stats ) diff --git a/src/apps/lichess_bridge/authentication.py b/src/apps/lichess_bridge/authentication.py index bd93917..a087a7a 100644 --- a/src/apps/lichess_bridge/authentication.py +++ b/src/apps/lichess_bridge/authentication.py @@ -7,6 +7,7 @@ # https://github.com/lakinwecker/lichess-oauth-flask/blob/master/app.py # Authlib "vanilla Python" usage: # https://docs.authlib.org/en/latest/client/oauth2.html +from __future__ import annotations import functools from typing import TYPE_CHECKING, Literal @@ -57,7 +58,7 @@ def from_cookie_content( *, zakuchess_hostname: str, zakuchess_protocol: str = "https", - ) -> "Self": + ) -> Self: cookie_content_dict = msgspec.json.decode(cookie_content) redirect_uri = _get_lichess_oauth2_zakuchess_redirect_uri( zakuchess_protocol, @@ -76,7 +77,7 @@ def create_afresh( *, zakuchess_hostname: str, zakuchess_protocol: str = "https", - ) -> "Self": + ) -> Self: """ Returns a context with randomly generated "CSRF state" and "code verifier". """ @@ -96,7 +97,7 @@ def create_afresh( class LichessToken(msgspec.Struct): token_type: Literal["Bearer"] - access_token: "LichessAccessToken" + access_token: LichessAccessToken expires_in: int # number of seconds expires_at: int # a Unix timestamp @@ -121,7 +122,7 @@ def get_lichess_token_retrieval_via_oauth2_process_starting_url( def check_csrf_state_from_oauth2_callback( - *, request: "HttpRequest", context: LichessTokenRetrievalProcessContext + *, request: HttpRequest, context: LichessTokenRetrievalProcessContext ): """ Raises a SuspiciousOperation if the state from the request's query string diff --git a/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py index 51ac7f9..6603b20 100644 --- a/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py +++ b/src/apps/lichess_bridge/business_logic/_create_teams_and_piece_role_by_square_for_starting_position.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools from typing import TYPE_CHECKING, cast @@ -28,17 +30,17 @@ @functools.cache def create_teams_and_piece_role_by_square_for_starting_position( - factions: "GameFactions", -) -> "tuple[GameTeams, PieceRoleBySquareTuple]": + factions: GameFactions, +) -> tuple[GameTeams, PieceRoleBySquareTuple]: # fmt: off - piece_counters: dict["PieceSymbol", int | None] = { + piece_counters: dict[PieceSymbol, int | None] = { "P": 0, "R": 0, "N": 0, "B": 0, "Q": None, "K": None, "p": 0, "r": 0, "n": 0, "b": 0, "q": None, "k": None, } # fmt: on - teams: "dict[PlayerSide, list[TeamMember]]" = {"w": [], "b": []} - piece_role_by_square: "PieceRoleBySquare" = {} + teams: dict[PlayerSide, list[TeamMember]] = {"w": [], "b": []} + piece_role_by_square: PieceRoleBySquare = {} chess_board = chess.Board() for chess_square in chess.SQUARES: piece = chess_board.piece_at(chess_square) diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py index 08e08db..2c3b1e8 100644 --- a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_moves.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import time from typing import TYPE_CHECKING, NamedTuple @@ -21,13 +23,13 @@ class RebuildGameFromMovesResult(NamedTuple): chess_board: chess.Board - teams: "GameTeams" - piece_role_by_square: "PieceRoleBySquare" - moves: "Sequence[UCIMove]" + teams: GameTeams + piece_role_by_square: PieceRoleBySquare + moves: Sequence[UCIMove] def rebuild_game_from_moves( - *, uci_moves: "Sequence[UCIMove]", factions: "GameFactions" + *, uci_moves: Sequence[UCIMove], factions: GameFactions ) -> RebuildGameFromMovesResult: from ._create_teams_and_piece_role_by_square_for_starting_position import ( create_teams_and_piece_role_by_square_for_starting_position, diff --git a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py index 27d2ce0..febc12a 100644 --- a/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py +++ b/src/apps/lichess_bridge/business_logic/_rebuild_game_from_pgn.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import time from typing import TYPE_CHECKING, NamedTuple @@ -21,13 +23,13 @@ class RebuildGameFromPgnResult(NamedTuple): chess_board: chess.Board - teams: "GameTeams" - piece_role_by_square: "PieceRoleBySquare" - moves: "Sequence[UCIMove]" + teams: GameTeams + piece_role_by_square: PieceRoleBySquare + moves: Sequence[UCIMove] def rebuild_game_from_pgn( - *, pgn_game: "chess.pgn.Game", factions: "GameFactions" + *, pgn_game: chess.pgn.Game, factions: GameFactions ) -> RebuildGameFromPgnResult: from ._create_teams_and_piece_role_by_square_for_starting_position import ( create_teams_and_piece_role_by_square_for_starting_position, diff --git a/src/apps/lichess_bridge/components/game_creation.py b/src/apps/lichess_bridge/components/game_creation.py index ebbc98a..788b11f 100644 --- a/src/apps/lichess_bridge/components/game_creation.py +++ b/src/apps/lichess_bridge/components/game_creation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.urls import reverse @@ -12,7 +14,7 @@ from django.http import HttpRequest -def game_creation_form(*, request: "HttpRequest", form_errors: dict) -> form: +def game_creation_form(*, request: HttpRequest, form_errors: dict) -> form: return form( csrf_hidden_input(request), div( diff --git a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py index 5ddbbe6..e33ca68 100644 --- a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py +++ b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.urls import reverse @@ -17,8 +19,8 @@ def user_profile_modal( - *, request: "HttpRequest", me: "LichessAccountInformation" -) -> "dom_tag": + *, request: HttpRequest, me: LichessAccountInformation +) -> dom_tag: return modal_container( header=h3( "Lichess account ", @@ -32,7 +34,7 @@ def user_profile_modal( ) -def _user_profile_form(request: "HttpRequest", me: "LichessAccountInformation") -> form: +def _user_profile_form(request: HttpRequest, me: LichessAccountInformation) -> form: spacing = "mb-3" return form( diff --git a/src/apps/lichess_bridge/components/no_linked_account.py b/src/apps/lichess_bridge/components/no_linked_account.py index ca3a78e..96e54fa 100644 --- a/src/apps/lichess_bridge/components/no_linked_account.py +++ b/src/apps/lichess_bridge/components/no_linked_account.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.urls import reverse @@ -16,7 +18,7 @@ _SHOWN_UNITS_FACTIONS = GameFactions(w="humans", b="undeads") -def no_linked_account_content(request: "HttpRequest") -> "dom_tag": +def no_linked_account_content(request: HttpRequest) -> dom_tag: return div( p( "You can play games with your friends and other people all around the world on ZakuChess, " diff --git a/src/apps/lichess_bridge/components/ongoing_games.py b/src/apps/lichess_bridge/components/ongoing_games.py index cb8881d..4423fcf 100644 --- a/src/apps/lichess_bridge/components/ongoing_games.py +++ b/src/apps/lichess_bridge/components/ongoing_games.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textwrap import dedent from typing import TYPE_CHECKING @@ -45,7 +47,7 @@ ) -def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "html_tag": +def lichess_ongoing_games(ongoing_games: list[LichessOngoingGameData]) -> html_tag: th_classes = "p-2" return div( @@ -81,7 +83,7 @@ def lichess_ongoing_games(ongoing_games: "list[LichessOngoingGameData]") -> "htm ) -def _ongoing_game_row(game: "LichessOngoingGameData") -> tr: +def _ongoing_game_row(game: LichessOngoingGameData) -> tr: td_classes = "border border-slate-300 dark:border-slate-700 p-1 text-slate-500 dark:text-slate-400" return tr( td( diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 902d14c..940961d 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, TypedDict from django.conf import settings @@ -47,7 +49,7 @@ def lichess_no_account_linked_page( *, - request: "HttpRequest", + request: HttpRequest, ) -> str: return page( section( @@ -69,9 +71,9 @@ def lichess_no_account_linked_page( def lichess_my_current_games_list_page( *, - request: "HttpRequest", - me: "LichessAccountInformation", - ongoing_games: "list[LichessOngoingGameData]", + request: HttpRequest, + me: LichessAccountInformation, + ongoing_games: list[LichessOngoingGameData], ) -> str: return page( section( @@ -101,9 +103,9 @@ def lichess_my_current_games_list_page( def lichess_correspondence_game_creation_page( - request: "HttpRequest", + request: HttpRequest, *, - me: "LichessAccountInformation", + me: LichessAccountInformation, form_errors: dict, ) -> str: return page( @@ -127,9 +129,9 @@ def lichess_correspondence_game_creation_page( def lichess_correspondence_game_page( *, - request: "HttpRequest", - me: "LichessAccountInformation", - game_presenter: "LichessCorrespondenceGamePresenter", + request: HttpRequest, + me: LichessAccountInformation, + game_presenter: LichessCorrespondenceGamePresenter, ) -> str: return page( chess_arena( @@ -146,46 +148,44 @@ def lichess_correspondence_game_page( def lichess_game_moving_parts_fragment( *, - game_presenter: "LichessCorrespondenceGamePresenter", - request: "HttpRequest", + game_presenter: LichessCorrespondenceGamePresenter, + request: HttpRequest, board_id: str, ) -> str: return "\n".join( - ( - dom_tag.render(pretty=settings.DEBUG) - for dom_tag in ( - chess_pieces( + dom_tag.render(pretty=settings.DEBUG) + for dom_tag in ( + chess_pieces( + game_presenter=game_presenter, + board_id=board_id, + ), + chess_available_targets( + game_presenter=game_presenter, + board_id=board_id, + data_hx_swap_oob="outerHTML", + ), + ( + chess_last_move( game_presenter=game_presenter, board_id=board_id, - ), - chess_available_targets( + data_hx_swap_oob="outerHTML", + ) + if game_presenter.refresh_last_move + else div("") + ), + div( + speech_bubble_container( game_presenter=game_presenter, board_id=board_id, - data_hx_swap_oob="outerHTML", - ), - ( - chess_last_move( - game_presenter=game_presenter, - board_id=board_id, - data_hx_swap_oob="outerHTML", - ) - if game_presenter.refresh_last_move - else div("") ), - div( - speech_bubble_container( - game_presenter=game_presenter, - board_id=board_id, - ), - id=f"chess-speech-container-{board_id}", - data_hx_swap_oob="innerHTML", - ), - ) + id=f"chess-speech-container-{board_id}", + data_hx_swap_oob="innerHTML", + ), ) ) -def _lichess_account_footer(me: "LichessAccountInformation") -> "dom_tag": +def _lichess_account_footer(me: LichessAccountInformation) -> dom_tag: return div( p( "Your Lichess account: ", @@ -203,8 +203,8 @@ def _lichess_account_footer(me: "LichessAccountInformation") -> "dom_tag": class _PageHeaderButtons(TypedDict): - left_side_buttons: list["dom_tag"] - right_side_buttons: list["dom_tag"] + left_side_buttons: list[dom_tag] + right_side_buttons: list[dom_tag] def _get_page_header_buttons(lichess_profile_linked: bool) -> _PageHeaderButtons: @@ -214,7 +214,7 @@ def _get_page_header_buttons(lichess_profile_linked: bool) -> _PageHeaderButtons ) -def _user_account_button() -> "dom_tag": +def _user_account_button() -> dom_tag: htmx_attributes = { "data_hx_get": reverse("lichess_bridge:htmx_modal_user_account"), "data_hx_target": "#modals-container", diff --git a/src/apps/lichess_bridge/cookie_helpers.py b/src/apps/lichess_bridge/cookie_helpers.py index d917a60..01eb57c 100644 --- a/src/apps/lichess_bridge/cookie_helpers.py +++ b/src/apps/lichess_bridge/cookie_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime as dt import logging from typing import TYPE_CHECKING, cast @@ -43,7 +45,7 @@ def store_oauth2_token_retrieval_context_in_response_cookie( - *, context: LichessTokenRetrievalProcessContext, response: "HttpResponse" + *, context: LichessTokenRetrievalProcessContext, response: HttpResponse ) -> None: """ Store OAuth2 token retrieval context into a short-lived response cookie. @@ -57,7 +59,7 @@ def store_oauth2_token_retrieval_context_in_response_cookie( def get_oauth2_token_retrieval_context_from_request( - request: "HttpRequest", + request: HttpRequest, ) -> LichessTokenRetrievalProcessContext | None: """ Returns a context created from the "CSRF state" and "code verifier" found in the request's cookies. @@ -81,13 +83,13 @@ def get_oauth2_token_retrieval_context_from_request( def delete_oauth2_token_retrieval_context_from_cookies( - response: "HttpResponse", + response: HttpResponse, ) -> None: response.delete_cookie(_OAUTH2_TOKEN_RETRIEVAL_CONTEXT_COOKIE_ATTRS.name) def store_lichess_api_access_token_in_response_cookie( - *, token: "LichessToken", response: "HttpResponse" + *, token: LichessToken, response: HttpResponse ) -> None: """ Store a Lichess API token into a long-lived response cookie. @@ -107,8 +109,8 @@ def store_lichess_api_access_token_in_response_cookie( def get_lichess_api_access_token_from_request( - request: "HttpRequest", -) -> "LichessAccessToken | None": + request: HttpRequest, +) -> LichessAccessToken | None: """ Returns a Lichess API token found in the request's cookies. """ @@ -127,6 +129,6 @@ def get_lichess_api_access_token_from_request( def delete_lichess_api_access_token_from_cookies( - response: "HttpResponse", + response: HttpResponse, ) -> None: response.delete_cookie(_API_ACCESS_TOKEN_COOKIE_ATTRS.name) diff --git a/src/apps/lichess_bridge/lichess_api.py b/src/apps/lichess_bridge/lichess_api.py index e65b7a8..1f541d7 100644 --- a/src/apps/lichess_bridge/lichess_api.py +++ b/src/apps/lichess_bridge/lichess_api.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import contextlib import datetime as dt @@ -100,7 +102,7 @@ class ResponseDataWrapper(msgspec.Struct): async def get_game_export_by_id( *, api_client: httpx.AsyncClient, - game_id: "LichessGameId", + game_id: LichessGameId, try_fetching_from_cache: bool = True, ) -> LichessGameExport: """ @@ -143,7 +145,7 @@ async def get_game_export_by_id( async def get_game_by_id_from_stream( *, api_client: httpx.AsyncClient, - game_id: "LichessGameId", + game_id: LichessGameId, try_fetching_from_cache: bool = True, ) -> LichessGameFullFromStream: """ @@ -181,7 +183,7 @@ async def get_game_by_id_from_stream( return msgspec.json.decode(response_content, type=LichessGameFullFromStream) -async def clear_game_by_id_cache(game_id: "LichessGameId") -> None: +async def clear_game_by_id_cache(game_id: LichessGameId) -> None: """ Clear the cached data of `get_game_export_by_id` and `get_game_by_id_from_stream` for a given game ID. @@ -202,9 +204,9 @@ async def clear_game_by_id_cache(game_id: "LichessGameId") -> None: async def move_lichess_game_piece( *, api_client: httpx.AsyncClient, - game_id: "LichessGameId", - from_: "Square", - to: "Square", + game_id: LichessGameId, + from_: Square, + to: Square, offering_draw: bool = False, ) -> bool: """ @@ -228,7 +230,7 @@ async def move_lichess_game_piece( async def create_correspondence_game( *, api_client: httpx.AsyncClient, days_per_turn: int -) -> "LichessGameSeekId": +) -> LichessGameSeekId: # https://lichess.org/api#tag/Board/operation/apiBoardSeek # TODO: give more customisation options to the user endpoint = "/api/board/seek" @@ -247,12 +249,12 @@ async def create_correspondence_game( return str(response.json()["id"]) -def get_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncClient: +def get_lichess_api_client(access_token: LichessAccessToken) -> httpx.AsyncClient: return _create_lichess_api_client(access_token) @contextlib.contextmanager -def _lichess_api_monitoring(method, target_endpoint) -> "Iterator[None]": +def _lichess_api_monitoring(method, target_endpoint) -> Iterator[None]: start_time = time.monotonic() yield _logger.info( @@ -265,7 +267,7 @@ def _lichess_api_monitoring(method, target_endpoint) -> "Iterator[None]": # This is the function we'll mock during tests - as it's private, we don't have to # mind about it being directly imported by other modules when we mock it. -def _create_lichess_api_client(access_token: "LichessAccessToken") -> httpx.AsyncClient: +def _create_lichess_api_client(access_token: LichessAccessToken) -> httpx.AsyncClient: client = httpx.AsyncClient( base_url=settings.LICHESS_HOST, headers={ diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 97ad53d..96b72ef 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import dataclasses import functools import io @@ -103,13 +105,13 @@ # For now we hard-code the fact that "me" always plays the "humans" faction, # and "them" always plays the "undeads" faction. -_FACTIONS_BY_BOARD_ORIENTATION: dict["BoardOrientation", GameFactions] = { +_FACTIONS_BY_BOARD_ORIENTATION: dict[BoardOrientation, GameFactions] = { "1->8": GameFactions(w="humans", b="undeads"), "8->1": GameFactions(w="undeads", b="humans"), } # Presenters are the objects we pass to our templates. -_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict["LichessPlayerSide", "PlayerSide"] = { +_LICHESS_PLAYER_SIDE_TO_PLAYER_SIDE_MAPPING: dict[LichessPlayerSide, PlayerSide] = { "white": "w", "black": "b", } @@ -261,30 +263,30 @@ def is_ongoing_game(self) -> bool: class LichessGameWithMetadataBase(ABC): @property @abstractmethod - def chess_board(self) -> "chess.Board": ... + def chess_board(self) -> chess.Board: ... @property @abstractmethod - def moves(self) -> "Sequence[UCIMove]": ... + def moves(self) -> Sequence[UCIMove]: ... @property @abstractmethod - def piece_role_by_square(self) -> "PieceRoleBySquare": ... + def piece_role_by_square(self) -> PieceRoleBySquare: ... @property @abstractmethod - def teams(self) -> "GameTeams": ... + def teams(self) -> GameTeams: ... @functools.cached_property - def active_player_side(self) -> "LichessPlayerSide": + def active_player_side(self) -> LichessPlayerSide: return "white" if self.chess_board.turn else "black" @property @abstractmethod - def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": ... + def players_from_my_perspective(self) -> LichessGameMetadataPlayers: ... @functools.cached_property - def board_orientation(self) -> "BoardOrientation": + def board_orientation(self) -> BoardOrientation: return self._players_sides.board_orientation @functools.cached_property @@ -293,7 +295,7 @@ def game_factions(self) -> GameFactions: @property @abstractmethod - def _players_sides(self) -> "LichessGameMetadataPlayerSides": ... + def _players_sides(self) -> LichessGameMetadataPlayerSides: ... @dataclasses.dataclass(frozen=True) @@ -304,34 +306,34 @@ class LichessGameFullFromStreamWithMetadata(LichessGameWithMetadataBase): """ raw_data: LichessGameFullFromStream - my_player_id: "LichessPlayerId" + my_player_id: LichessPlayerId @functools.cached_property - def chess_board(self) -> "chess.Board": + def chess_board(self) -> chess.Board: return self._rebuilt_game.chess_board @functools.cached_property - def moves(self) -> "Sequence[UCIMove]": + def moves(self) -> Sequence[UCIMove]: return self._rebuilt_game.moves @functools.cached_property - def piece_role_by_square(self) -> "PieceRoleBySquare": + def piece_role_by_square(self) -> PieceRoleBySquare: return self._rebuilt_game.piece_role_by_square @functools.cached_property - def teams(self) -> "GameTeams": + def teams(self) -> GameTeams: return self._rebuilt_game.teams @functools.cached_property - def active_player_side(self) -> "LichessPlayerSide": + def active_player_side(self) -> LichessPlayerSide: return "white" if self.chess_board.turn else "black" @functools.cached_property - def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": + def players_from_my_perspective(self) -> LichessGameMetadataPlayers: my_side, their_side, _ = self._players_sides - my_player: "LichessGameEventPlayer" = getattr(self.raw_data, my_side) - their_player: "LichessGameEventPlayer" = getattr(self.raw_data, their_side) + my_player: LichessGameEventPlayer = getattr(self.raw_data, my_side) + their_player: LichessGameEventPlayer = getattr(self.raw_data, their_side) result = LichessGameMetadataPlayers( me=LichessGameMetadataPlayer( @@ -352,12 +354,12 @@ def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": return result @functools.cached_property - def _players_sides(self) -> "LichessGameMetadataPlayerSides": - my_side: "LichessPlayerSide" = ( + def _players_sides(self) -> LichessGameMetadataPlayerSides: + my_side: LichessPlayerSide = ( "white" if self.raw_data.white.id == self.my_player_id else "black" ) - their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" - board_orientation: "BoardOrientation" = "1->8" if my_side == "white" else "8->1" + their_side: LichessPlayerSide = "black" if my_side == "white" else "white" + board_orientation: BoardOrientation = "1->8" if my_side == "white" else "8->1" return LichessGameMetadataPlayerSides( me=my_side, @@ -366,7 +368,7 @@ def _players_sides(self) -> "LichessGameMetadataPlayerSides": ) @functools.cached_property - def _rebuilt_game(self) -> "RebuildGameFromMovesResult": + def _rebuilt_game(self) -> RebuildGameFromMovesResult: return rebuild_game_from_moves( uci_moves=self.raw_data.state.moves.strip().split(" "), factions=self.game_factions, @@ -381,43 +383,41 @@ class LichessGameExportWithMetadata(LichessGameWithMetadataBase): """ raw_data: LichessGameExport - my_player_id: "LichessPlayerId" + my_player_id: LichessPlayerId @functools.cached_property - def pgn_game(self) -> "chess.pgn.Game": + def pgn_game(self) -> chess.pgn.Game: pgn_game = chess.pgn.read_game(io.StringIO(self.raw_data.pgn)) if not pgn_game: raise ValueError("Could not read PGN game") return pgn_game @functools.cached_property - def chess_board(self) -> "chess.Board": + def chess_board(self) -> chess.Board: return self._rebuilt_game.chess_board @functools.cached_property - def moves(self) -> "Sequence[UCIMove]": + def moves(self) -> Sequence[UCIMove]: return self._rebuilt_game.moves @functools.cached_property - def piece_role_by_square(self) -> "PieceRoleBySquare": + def piece_role_by_square(self) -> PieceRoleBySquare: return self._rebuilt_game.piece_role_by_square @functools.cached_property - def teams(self) -> "GameTeams": + def teams(self) -> GameTeams: return self._rebuilt_game.teams @functools.cached_property - def active_player_side(self) -> "LichessPlayerSide": + def active_player_side(self) -> LichessPlayerSide: return "white" if self.chess_board.turn else "black" @functools.cached_property - def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": + def players_from_my_perspective(self) -> LichessGameMetadataPlayers: my_side, their_side, _ = self._players_sides - my_player: "LichessGameUser" = getattr(self.raw_data.players, my_side).user - their_player: "LichessGameUser" = getattr( - self.raw_data.players, their_side - ).user + my_player: LichessGameUser = getattr(self.raw_data.players, my_side).user + their_player: LichessGameUser = getattr(self.raw_data.players, their_side).user result = LichessGameMetadataPlayers( me=LichessGameMetadataPlayer( @@ -438,14 +438,14 @@ def players_from_my_perspective(self) -> "LichessGameMetadataPlayers": return result @functools.cached_property - def _players_sides(self) -> "LichessGameMetadataPlayerSides": - my_side: "LichessPlayerSide" = ( + def _players_sides(self) -> LichessGameMetadataPlayerSides: + my_side: LichessPlayerSide = ( "white" if self.raw_data.players.white.user.id == self.my_player_id else "black" ) - their_side: "LichessPlayerSide" = "black" if my_side == "white" else "white" - board_orientation: "BoardOrientation" = "1->8" if my_side == "white" else "8->1" + their_side: LichessPlayerSide = "black" if my_side == "white" else "white" + board_orientation: BoardOrientation = "1->8" if my_side == "white" else "8->1" return LichessGameMetadataPlayerSides( me=my_side, @@ -454,23 +454,23 @@ def _players_sides(self) -> "LichessGameMetadataPlayerSides": ) @functools.cached_property - def _rebuilt_game(self) -> "RebuildGameFromPgnResult": + def _rebuilt_game(self) -> RebuildGameFromPgnResult: return rebuild_game_from_pgn( pgn_game=self.pgn_game, factions=self.game_factions ) class LichessGameMetadataPlayerSides(NamedTuple): - me: "LichessPlayerSide" - them: "LichessPlayerSide" - board_orientation: "BoardOrientation" + me: LichessPlayerSide + them: LichessPlayerSide + board_orientation: BoardOrientation class LichessGameMetadataPlayer(NamedTuple): id: LichessPlayerId username: LichessGameFullId - player_side: "PlayerSide" - faction: "Faction" + player_side: PlayerSide + faction: Faction class LichessGameMetadataPlayers(NamedTuple): diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 1df9270..8dea4a3 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from functools import cached_property from typing import TYPE_CHECKING, cast from urllib.parse import urlencode @@ -25,11 +27,11 @@ class LichessCorrespondenceGamePresenter(GamePresenter): def __init__( self, *, - game_data: "LichessGameFullFromStreamWithMetadata", + game_data: LichessGameFullFromStreamWithMetadata, refresh_last_move: bool, is_htmx_request: bool, - selected_piece_square: "Square | None" = None, - user_prefs: "UserPrefs | None" = None, + selected_piece_square: Square | None = None, + user_prefs: UserPrefs | None = None, ): self._game_data = game_data @@ -53,11 +55,11 @@ def __init__( ) @cached_property - def board_orientation(self) -> "BoardOrientation": + def board_orientation(self) -> BoardOrientation: return self._game_data.board_orientation @cached_property - def urls(self) -> "GamePresenterUrls": + def urls(self) -> GamePresenterUrls: return LichessCorrespondenceGamePresenterUrls(game_presenter=self) @cached_property @@ -65,11 +67,11 @@ def is_my_turn(self) -> bool: return self._game_data.players_from_my_perspective.active_player == "me" @cached_property - def my_side(self) -> "PlayerSide | None": + def my_side(self) -> PlayerSide | None: return self._game_data.players_from_my_perspective.me.player_side @cached_property - def game_phase(self) -> "GamePhase": + def game_phase(self) -> GamePhase: # TODO: manage "game over" situations if self.is_my_turn: if self.selected_piece is None: @@ -92,7 +94,7 @@ def game_id(self) -> str: return self._game_data.raw_data.id @cached_property - def factions(self) -> "GameFactions": + def factions(self) -> GameFactions: return self._game_data.game_factions @property @@ -100,11 +102,11 @@ def is_intro_turn(self) -> bool: return False @cached_property - def player_side_to_highlight_all_pieces_for(self) -> "PlayerSide | None": + def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: return None @cached_property - def speech_bubble(self) -> "SpeechBubbleData | None": + def speech_bubble(self) -> SpeechBubbleData | None: return None @@ -123,7 +125,7 @@ def htmx_game_no_selection_url(self, *, board_id: str) -> str: ) ) - def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: return "".join( ( reverse( @@ -138,7 +140,7 @@ def htmx_game_select_piece_url(self, *, square: "Square", board_id: str) -> str: ) ) - def htmx_game_move_piece_url(self, *, square: "Square", board_id: str) -> str: + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: assert self._game_presenter.selected_piece is not None # type checker: happy return "".join( ( diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 522570c..4b56243 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import contextlib import json from http import HTTPStatus @@ -27,7 +29,7 @@ def raise_for_status(self): pass -def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): +def test_lichess_homepage_no_access_token_smoke_test(client: DjangoClient): """Just a quick smoke test for now""" response = client.get("/lichess/") @@ -40,7 +42,7 @@ def test_lichess_homepage_no_access_token_smoke_test(client: "DjangoClient"): @pytest.mark.django_db # just because we use the DatabaseCache async def test_lichess_homepage_with_access_token_smoke_test( - async_client: "DjangoAsyncClient", + async_client: DjangoAsyncClient, acleared_django_default_cache, ): """Just a quick smoke test for now""" @@ -92,7 +94,7 @@ async def get(self, path, **kwargs): async def test_lichess_create_game_without_access_token_should_redirect( - async_client: "DjangoAsyncClient", + async_client: DjangoAsyncClient, ): response = await async_client.get("/lichess/games/new/") @@ -101,7 +103,7 @@ async def test_lichess_create_game_without_access_token_should_redirect( @pytest.mark.django_db # just because we use the DatabaseCache async def test_lichess_create_game_with_access_token_smoke_test( - async_client: "DjangoAsyncClient", + async_client: DjangoAsyncClient, ): """Just a quick smoke test for now""" @@ -114,7 +116,7 @@ async def test_lichess_create_game_with_access_token_smoke_test( async def test_lichess_correspondence_game_without_access_token_should_redirect( - async_client: "DjangoAsyncClient", + async_client: DjangoAsyncClient, ): response = await async_client.get("/lichess/games/correspondence/tFXGsEvq/") @@ -208,7 +210,7 @@ async def test_lichess_correspondence_game_without_access_token_should_redirect( @pytest.mark.django_db # just because we use the DatabaseCache async def test_lichess_correspondence_game_with_access_token_smoke_test( - async_client: "DjangoAsyncClient", + async_client: DjangoAsyncClient, acleared_django_default_cache, ): """Just a quick smoke test for now""" diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 3487c72..1d595f9 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio from typing import TYPE_CHECKING @@ -53,7 +55,7 @@ @require_safe @with_lichess_access_token async def lichess_home_page( - request: "HttpRequest", lichess_access_token: "LichessAccessToken | None" + request: HttpRequest, lichess_access_token: LichessAccessToken | None ) -> HttpResponse: if not lichess_access_token: page_content = lichess_pages.lichess_no_account_linked_page(request=request) @@ -70,7 +72,7 @@ async def lichess_home_page( @with_lichess_access_token @redirect_if_no_lichess_access_token async def lichess_my_games_list_page( - request: "HttpRequest", lichess_access_token: "LichessAccessToken" + request: HttpRequest, lichess_access_token: LichessAccessToken ) -> HttpResponse: page_content = await _get_my_games_list_page_content( request=request, @@ -84,7 +86,7 @@ async def lichess_my_games_list_page( @with_lichess_access_token @redirect_if_no_lichess_access_token async def lichess_game_create_form_page( - request: "HttpRequest", *, lichess_access_token: "LichessAccessToken" + request: HttpRequest, *, lichess_access_token: LichessAccessToken ) -> HttpResponse: me = await _get_me_from_lichess(lichess_access_token) @@ -118,11 +120,11 @@ async def lichess_game_create_form_page( @with_user_prefs @redirect_if_no_lichess_access_token async def lichess_correspondence_game_page( - request: "HttpRequest", + request: HttpRequest, *, - lichess_access_token: "LichessAccessToken", - game_id: "LichessGameId", - user_prefs: "UserPrefs | None", + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + user_prefs: UserPrefs | None, ) -> HttpResponse: me, game_data = await _get_game_context_from_lichess( lichess_access_token, game_id, use_game_cache=False @@ -148,11 +150,11 @@ async def lichess_correspondence_game_page( @with_user_prefs @redirect_if_no_lichess_access_token async def htmx_lichess_correspondence_game_no_selection( - request: "HttpRequest", + request: HttpRequest, *, - lichess_access_token: "LichessAccessToken", - game_id: "LichessGameId", - user_prefs: "UserPrefs | None", + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + user_prefs: UserPrefs | None, ) -> HttpResponse: me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( @@ -173,12 +175,12 @@ async def htmx_lichess_correspondence_game_no_selection( @redirect_if_no_lichess_access_token @handle_chess_logic_exceptions async def htmx_game_select_piece( - request: "HttpRequest", + request: HttpRequest, *, - lichess_access_token: "LichessAccessToken", - game_id: "LichessGameId", - location: "Square", - user_prefs: "UserPrefs | None", + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + location: Square, + user_prefs: UserPrefs | None, ) -> HttpResponse: me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) game_presenter = LichessCorrespondenceGamePresenter( @@ -200,13 +202,13 @@ async def htmx_game_select_piece( @redirect_if_no_lichess_access_token @handle_chess_logic_exceptions async def htmx_game_move_piece( - request: "HttpRequest", + request: HttpRequest, *, - lichess_access_token: "LichessAccessToken", - game_id: "LichessGameId", - from_: "Square", - to: "Square", - user_prefs: "UserPrefs | None", + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + from_: Square, + to: Square, + user_prefs: UserPrefs | None, ) -> HttpResponse: if from_ == to: raise ChessInvalidMoveException("Not a move") @@ -259,9 +261,9 @@ async def htmx_game_move_piece( @with_lichess_access_token @redirect_if_no_lichess_access_token async def htmx_user_account_modal( - request: "HttpRequest", + request: HttpRequest, *, - lichess_access_token: "LichessAccessToken", + lichess_access_token: LichessAccessToken, ) -> HttpResponse: me = await _get_me_from_lichess(lichess_access_token) @@ -272,7 +274,7 @@ async def htmx_user_account_modal( @require_POST def lichess_redirect_to_oauth2_flow_starting_url( - request: "HttpRequest", + request: HttpRequest, ) -> HttpResponse: lichess_oauth2_process_context = LichessTokenRetrievalProcessContext.create_afresh( zakuchess_hostname=request.get_host(), @@ -293,7 +295,7 @@ def lichess_redirect_to_oauth2_flow_starting_url( @require_safe -def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpResponse: +def lichess_webhook_oauth2_token_callback(request: HttpRequest) -> HttpResponse: # Retrieve a context from the HTTP-only cookie we created above: lichess_oauth2_process_context = ( cookie_helpers.get_oauth2_token_retrieval_context_from_request(request) @@ -331,7 +333,7 @@ def lichess_webhook_oauth2_token_callback(request: "HttpRequest") -> HttpRespons @require_POST -def lichess_detach_account(request: "HttpRequest") -> HttpResponse: +def lichess_detach_account(request: HttpRequest) -> HttpResponse: response = redirect("lichess_bridge:homepage") cookie_helpers.delete_lichess_api_access_token_from_cookies(response=response) @@ -342,7 +344,7 @@ def lichess_detach_account(request: "HttpRequest") -> HttpResponse: def _lichess_game_moving_parts_fragment_response( *, game_presenter: LichessCorrespondenceGamePresenter, - request: "HttpRequest", + request: HttpRequest, board_id: str, ) -> HttpResponse: return HttpResponse( @@ -354,8 +356,8 @@ def _lichess_game_moving_parts_fragment_response( async def _get_my_games_list_page_content( *, - request: "HttpRequest", - lichess_access_token: "LichessAccessToken", + request: HttpRequest, + lichess_access_token: LichessAccessToken, ) -> str: async with lichess_api.get_lichess_api_client( access_token=lichess_access_token @@ -377,8 +379,8 @@ async def _get_my_games_list_page_content( async def _get_me_from_lichess( - lichess_access_token: "LichessAccessToken", -) -> "LichessAccountInformation": + lichess_access_token: LichessAccessToken, +) -> LichessAccountInformation: async with lichess_api.get_lichess_api_client( access_token=lichess_access_token ) as lichess_api_client: @@ -386,10 +388,10 @@ async def _get_me_from_lichess( async def _get_game_context_from_lichess( - lichess_access_token: "LichessAccessToken", - game_id: "LichessGameId", + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, use_game_cache: bool = True, -) -> tuple["LichessAccountInformation", "LichessGameFullFromStreamWithMetadata"]: +) -> tuple[LichessAccountInformation, LichessGameFullFromStreamWithMetadata]: async with lichess_api.get_lichess_api_client( access_token=lichess_access_token ) as lichess_api_client: diff --git a/src/apps/lichess_bridge/views_decorators.py b/src/apps/lichess_bridge/views_decorators.py index d8b42bf..717b24a 100644 --- a/src/apps/lichess_bridge/views_decorators.py +++ b/src/apps/lichess_bridge/views_decorators.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import functools from typing import TYPE_CHECKING @@ -19,7 +21,7 @@ def with_lichess_access_token(func): if iscoroutinefunction(func): @functools.wraps(func) - async def wrapper(request: "HttpRequest", *args, **kwargs): + async def wrapper(request: HttpRequest, *args, **kwargs): lichess_access_token = ( cookie_helpers.get_lichess_api_access_token_from_request(request) ) @@ -30,7 +32,7 @@ async def wrapper(request: "HttpRequest", *args, **kwargs): else: @functools.wraps(func) - def wrapper(request: "HttpRequest", *args, **kwargs): + def wrapper(request: HttpRequest, *args, **kwargs): lichess_access_token = ( cookie_helpers.get_lichess_api_access_token_from_request(request) ) @@ -45,14 +47,14 @@ def with_user_prefs(func): if iscoroutinefunction(func): @functools.wraps(func) - async def wrapper(request: "HttpRequest", *args, **kwargs): + async def wrapper(request: HttpRequest, *args, **kwargs): user_prefs = get_user_prefs_from_request(request) return await func(request, *args, user_prefs=user_prefs, **kwargs) else: @functools.wraps(func) - def wrapper(request: "HttpRequest", *args, **kwargs): + def wrapper(request: HttpRequest, *args, **kwargs): user_prefs = get_user_prefs_from_request(request) return func(request, *args, user_prefs=user_prefs, **kwargs) @@ -64,9 +66,9 @@ def redirect_if_no_lichess_access_token(func): @functools.wraps(func) async def wrapper( - request: "HttpRequest", + request: HttpRequest, *args, - lichess_access_token: "LichessAccessToken | None", + lichess_access_token: LichessAccessToken | None, **kwargs, ): if not lichess_access_token: @@ -79,9 +81,9 @@ async def wrapper( @functools.wraps(func) def wrapper( - request: "HttpRequest", + request: HttpRequest, *args, - lichess_access_token: "LichessAccessToken | None", + lichess_access_token: LichessAccessToken | None, **kwargs, ): if not lichess_access_token: diff --git a/src/apps/utils/view_decorators.py b/src/apps/utils/view_decorators.py index 9ae6240..015c997 100644 --- a/src/apps/utils/view_decorators.py +++ b/src/apps/utils/view_decorators.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -6,5 +8,5 @@ from apps.authentication.models import User -def user_is_staff(user: "User | AnonymousUser") -> bool: +def user_is_staff(user: User | AnonymousUser) -> bool: return user.is_staff diff --git a/src/apps/utils/views_helpers.py b/src/apps/utils/views_helpers.py index eed43ae..e82f1db 100644 --- a/src/apps/utils/views_helpers.py +++ b/src/apps/utils/views_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, cast from django.shortcuts import redirect, resolve_url @@ -8,7 +10,7 @@ from django_htmx.middleware import HtmxDetails -def htmx_aware_redirect(request: "HttpRequest", url: str) -> "HttpResponse": +def htmx_aware_redirect(request: HttpRequest, url: str) -> HttpResponse: htmx_details = cast("HtmxDetails", getattr(request, "htmx")) if htmx_details: return HttpResponseClientRedirect(resolve_url(url)) diff --git a/src/apps/webui/components/chess_units.py b/src/apps/webui/components/chess_units.py index 91f4def..e11ab3c 100644 --- a/src/apps/webui/components/chess_units.py +++ b/src/apps/webui/components/chess_units.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import random from typing import TYPE_CHECKING, cast @@ -20,7 +22,7 @@ TeamMemberRole, ) -CHARACTER_TYPE_TIP: dict["PieceType", str] = { +CHARACTER_TYPE_TIP: dict[PieceType, str] = { # TODO: i18n "p": "Characters with swords", "n": "Mounted characters", @@ -31,7 +33,7 @@ } _CHARACTER_TYPE_TIP_KEYS = tuple(CHARACTER_TYPE_TIP.keys()) -_CHARACTER_TYPE_ROLE_MAPPING: dict["PieceType", "TeamMemberRole"] = { +_CHARACTER_TYPE_ROLE_MAPPING: dict[PieceType, TeamMemberRole] = { "p": "p1", "n": "n1", "b": "b1", @@ -43,11 +45,11 @@ def chess_status_bar_tip( *, - factions: "GameFactions", - piece_type: "PieceType | None" = None, + factions: GameFactions, + piece_type: PieceType | None = None, additional_classes: str = "", row_counter: int | None = None, -) -> "dom_tag": +) -> dom_tag: if piece_type is None: piece_type = random.choice(_CHARACTER_TYPE_TIP_KEYS) piece_name = PIECE_TYPE_TO_NAME[piece_type] @@ -76,11 +78,11 @@ def chess_status_bar_tip( def unit_display_container( *, - piece_role: "PieceRole", - factions: "GameFactions", + piece_role: PieceRole, + factions: GameFactions, row_counter: int | None = None, additional_classes: str = "", -) -> "dom_tag": +) -> dom_tag: from apps.chess.components.chess_board import chess_unit_display_with_ground_marker unit_display = chess_unit_display_with_ground_marker( @@ -100,15 +102,15 @@ def unit_display_container( ) -def character_type_tip(piece_type: "PieceType") -> "dom_tag": +def character_type_tip(piece_type: PieceType) -> dom_tag: return raw( f"{CHARACTER_TYPE_TIP[piece_type]} are chess {PIECE_TYPE_TO_NAME[piece_type]}s" ) def chess_unit_symbol_display( - *, player_side: "PlayerSide", piece_name: "PieceName" -) -> "dom_tag": + *, player_side: PlayerSide, piece_name: PieceName +) -> dom_tag: classes = ( "inline-block", "w-5", diff --git a/src/apps/webui/components/forms_common.py b/src/apps/webui/components/forms_common.py index 9168859..c0f1d86 100644 --- a/src/apps/webui/components/forms_common.py +++ b/src/apps/webui/components/forms_common.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.middleware.csrf import get_token as get_csrf_token @@ -7,7 +9,7 @@ from django.http import HttpRequest -def csrf_hidden_input(request: "HttpRequest") -> input_: +def csrf_hidden_input(request: HttpRequest) -> input_: return input_( type="hidden", name="csrfmiddlewaretoken", value=get_csrf_token(request) ) diff --git a/src/apps/webui/components/layout.py b/src/apps/webui/components/layout.py index a8e50f4..740edb0 100644 --- a/src/apps/webui/components/layout.py +++ b/src/apps/webui/components/layout.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import base64 import json from functools import cache @@ -51,12 +53,12 @@ def page( - *children: "dom_tag", - request: "HttpRequest", + *children: dom_tag, + request: HttpRequest, title: str = _META_TITLE, - left_side_buttons: "list[dom_tag] | None" = None, - right_side_buttons: "list[dom_tag] | None" = None, - head_children: "Sequence[dom_tag] | None" = None, + left_side_buttons: list[dom_tag] | None = None, + right_side_buttons: list[dom_tag] | None = None, + head_children: Sequence[dom_tag] | None = None, ) -> str: return "" + str( document( @@ -71,13 +73,13 @@ def page( def document( - *children: "dom_tag", - request: "HttpRequest", + *children: dom_tag, + request: HttpRequest, title: str, - left_side_buttons: "list[dom_tag] | None", - right_side_buttons: "list[dom_tag] | None" = None, - head_children: "Sequence[dom_tag] | None" = None, -) -> "dom_tag": + left_side_buttons: list[dom_tag] | None, + right_side_buttons: list[dom_tag] | None = None, + head_children: Sequence[dom_tag] | None = None, +) -> dom_tag: return html( head(*(head_children or []), title=title), body( @@ -100,7 +102,7 @@ def document( ) -def head(*children: "dom_tag", title: str) -> "dom_tag": +def head(*children: dom_tag, title: str) -> dom_tag: return base_head( comment( "ZakuChess is open source! See https://github.com/olivierphi/zakuchess" @@ -150,10 +152,10 @@ def head(*children: "dom_tag", title: str) -> "dom_tag": def header( *, - left_side_buttons: "list[dom_tag] | None", - right_side_buttons: "list[dom_tag] | None" = None, -) -> "dom_tag": - def side_wrapper(*children: "dom_tag", align: str) -> "dom_tag": + left_side_buttons: list[dom_tag] | None, + right_side_buttons: list[dom_tag] | None = None, +) -> dom_tag: + def side_wrapper(*children: dom_tag, align: str) -> dom_tag: return div( *children, cls=f"flex w-1/6 {align}", @@ -181,7 +183,7 @@ def side_wrapper(*children: "dom_tag", align: str) -> "dom_tag": @cache -def footer() -> "text": +def footer() -> text: GITHUB_SVG = b"""""" svg_b64 = base64.b64encode(GITHUB_SVG).decode("utf-8") @@ -225,7 +227,7 @@ def footer() -> "text": @cache -def modals_container() -> "text": +def modals_container() -> text: return raw( div( script( diff --git a/src/apps/webui/components/misc_ui/header.py b/src/apps/webui/components/misc_ui/header.py index e6fdf98..fc27705 100644 --- a/src/apps/webui/components/misc_ui/header.py +++ b/src/apps/webui/components/misc_ui/header.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from dominate.tags import button @@ -8,7 +10,7 @@ def header_button( *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] -) -> "dom_tag": +) -> dom_tag: return button( icon, cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", diff --git a/src/apps/webui/components/misc_ui/user_prefs_modal.py b/src/apps/webui/components/misc_ui/user_prefs_modal.py index 793bf36..64fcbff 100644 --- a/src/apps/webui/components/misc_ui/user_prefs_modal.py +++ b/src/apps/webui/components/misc_ui/user_prefs_modal.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.urls import reverse @@ -23,7 +25,7 @@ # TODO: manage i18n -def user_prefs_button() -> "dom_tag": +def user_prefs_button() -> dom_tag: htmx_attributes = { "data_hx_get": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", @@ -38,7 +40,7 @@ def user_prefs_button() -> "dom_tag": ) -def user_prefs_modal(*, user_prefs: "UserPrefs") -> "dom_tag": +def user_prefs_modal(*, user_prefs: UserPrefs) -> dom_tag: return modal_container( header=h3( "Preferences ", @@ -53,7 +55,7 @@ def user_prefs_modal(*, user_prefs: "UserPrefs") -> "dom_tag": ) -def _user_prefs_form(user_prefs: "UserPrefs") -> "dom_tag": +def _user_prefs_form(user_prefs: UserPrefs) -> dom_tag: form_htmx_attributes = { "data_hx_post": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", @@ -101,9 +103,9 @@ def _form_fieldset( *, fieldset_legend: str, input_name: str, - choices: "type[Choices]", + choices: type[Choices], # choices_icons: dict, - current_value: "Any", + current_value: Any, ) -> fieldset: return fieldset( legend( diff --git a/src/apps/webui/cookie_helpers.py b/src/apps/webui/cookie_helpers.py index c72491d..96c91b7 100644 --- a/src/apps/webui/cookie_helpers.py +++ b/src/apps/webui/cookie_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import datetime as dt import logging from typing import TYPE_CHECKING @@ -24,7 +26,7 @@ _logger = logging.getLogger(__name__) -def get_user_prefs_from_request(request: "HttpRequest") -> UserPrefs: +def get_user_prefs_from_request(request: HttpRequest) -> UserPrefs: def new_content(): return UserPrefs() @@ -42,7 +44,7 @@ def new_content(): return new_content() -def save_user_prefs(*, user_prefs: "UserPrefs", response: "HttpResponse") -> None: +def save_user_prefs(*, user_prefs: UserPrefs, response: HttpResponse) -> None: set_http_cookie_on_django_response( response=response, attributes=_USER_PREFS_COOKIE_ATTRS, diff --git a/src/apps/webui/forms.py b/src/apps/webui/forms.py index c642482..b4cfeb3 100644 --- a/src/apps/webui/forms.py +++ b/src/apps/webui/forms.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django import forms from apps.chess.models import ( diff --git a/src/apps/webui/views.py b/src/apps/webui/views.py index 9ab4f49..9d90606 100644 --- a/src/apps/webui/views.py +++ b/src/apps/webui/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING from django.http import HttpResponse @@ -13,7 +15,7 @@ @require_http_methods(["HEAD", "GET", "POST"]) -def htmx_user_prefs_modal(request: "HttpRequest") -> HttpResponse: +def htmx_user_prefs_modal(request: HttpRequest) -> HttpResponse: if request.method == "POST": # As user preferences updates can have an impact on any part of the UI # (changing the way the chess board is displayed, for example), we'd better diff --git a/src/lib/django_choices_helpers.py b/src/lib/django_choices_helpers.py index 9d095b4..4ecd4bd 100644 --- a/src/lib/django_choices_helpers.py +++ b/src/lib/django_choices_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Literal, TypeAlias if TYPE_CHECKING: @@ -7,9 +9,9 @@ DjangoChoice: TypeAlias = tuple[str | int | float, str] -def enum_to_django_choices(enum_class: "type[enum.Enum]") -> "Sequence[DjangoChoice]": +def enum_to_django_choices(enum_class: type[enum.Enum]) -> Sequence[DjangoChoice]: return [(enum_member.name, enum_member.value) for enum_member in enum_class] -def literal_to_django_choices(literal: "type[Literal]") -> "Sequence[DjangoChoice]": # type: ignore[valid-type] +def literal_to_django_choices(literal: type[Literal]) -> Sequence[DjangoChoice]: # type: ignore[valid-type] return [(value, value) for value in literal.__args__] diff --git a/src/lib/http_cookies_helpers.py b/src/lib/http_cookies_helpers.py index 54916e0..d3abae5 100644 --- a/src/lib/http_cookies_helpers.py +++ b/src/lib/http_cookies_helpers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import TYPE_CHECKING, Literal, NamedTuple if TYPE_CHECKING: @@ -8,14 +10,14 @@ class HttpCookieAttributes(NamedTuple): name: str - max_age: "dt.timedelta | None" + max_age: dt.timedelta | None http_only: bool # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value same_site: Literal["Strict", "Lax", "None", None] = "Lax" def set_http_cookie_on_django_response( - *, response: "HttpResponse", attributes: HttpCookieAttributes, value: str + *, response: HttpResponse, attributes: HttpCookieAttributes, value: str ) -> None: response.set_cookie( attributes.name, diff --git a/src/project/settings/_base.py b/src/project/settings/_base.py index c05fd54..71f18bf 100644 --- a/src/project/settings/_base.py +++ b/src/project/settings/_base.py @@ -9,6 +9,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ +from __future__ import annotations from os import environ as env from pathlib import Path diff --git a/src/project/tests/test_alive_probe.py b/src/project/tests/test_alive_probe.py index 5185299..0d41282 100644 --- a/src/project/tests/test_alive_probe.py +++ b/src/project/tests/test_alive_probe.py @@ -1,10 +1,12 @@ +from __future__ import annotations + from typing import TYPE_CHECKING if TYPE_CHECKING: from django.test import Client as DjangoClient -def test_alive(client: "DjangoClient"): +def test_alive(client: DjangoClient): resp = client.get("/-/alive/") assert resp.status_code == 200 assert resp.content == b"ok" From 34a0378f96b1b170249ea5bbd731d465c2b739d2 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 11:54:57 +0000 Subject: [PATCH 38/42] [chore] Update `uv`, update Django version, some TLC for the GH Actions --- .github/workflows/test-suite.yml | 17 +++-- Dockerfile | 2 +- Makefile | 9 +-- pyproject.toml | 8 +- uv.lock | 121 ++++++++++++------------------- 5 files changed, 66 insertions(+), 91 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index b39e253..48c91f3 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -19,29 +19,36 @@ jobs: runs-on: "ubuntu-latest" steps: + # Setup - uses: "actions/checkout@v4" - name: Install uv - uses: astral-sh/setup-uv@v2 + uses: astral-sh/setup-uv@v4 with: - version: "0.4.9" + version: "0.5.7" enable-cache: true cache-dependency-glob: "uv.lock" - - name: Set up Python + - name: Install Python, via uv run: uv python install - name: "Install dependencies via uv" run: uv sync --all-extras - name: "Install the project in 'editable' mode" run: uv pip install -e . + + # Code quality checks - name: "Run linting checks: Ruff checker" run: uv run --no-sync ruff format --check --quiet src/ - name: "Run linting checks: Ruff linter" run: uv run --no-sync ruff check --quiet src/ - name: "Run linting checks: Mypy" run: uv run --no-sync mypy src/ + - name: "Run linting checks: fix-future-annotations" + run: uv run --no-sync fix-future-annotations src/ - name: "Check that Django DB migrations are up to date" - run: uv run --no-sync python manage.py makemigrations | grep "No changes detected" + run: uv run --no-sync python manage.py makemigrations --check + + # Test suite & code coverage - name: "Run tests" - # TODO: progressively increase minimum coverage to something closer to 80% + # TODO: progressively increase minimum coverage to something closer to 80% run: uv run --no-sync pytest --cov=src --cov-report xml:coverage.xml # --cov-fail-under=60 --> we'll actually do that with the "Report coverage" step - name: "Report coverage" diff --git a/Dockerfile b/Dockerfile index 40334df..9f6ab30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,7 +75,7 @@ EOT # Install uv. # https://docs.astral.sh/uv/guides/integration/docker/ -COPY --from=ghcr.io/astral-sh/uv:0.4.9 /uv /usr/local/bin/uv +COPY --from=ghcr.io/astral-sh/uv:0.5.7 /uv /usr/local/bin/uv RUN mkdir -p /app WORKDIR /app diff --git a/Makefile b/Makefile index 0981b35..e58e1c9 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,7 @@ PYTHON_BINS ?= ./.venv/bin PYTHON ?= ${PYTHON_BINS}/python DJANGO_SETTINGS_MODULE ?= project.settings.development SUB_MAKE = ${MAKE} --no-print-directory -UV_PYTHON ?= ${PYTHON} UV ?= bin/uv -UVX ?= bin/uvx .DEFAULT_GOAL := help @@ -116,8 +114,7 @@ code-quality/mypy: ## Python's equivalent of TypeScript code-quality/fix-future-annotations: fix_future_annotations_opts ?= code-quality/fix-future-annotations: ## Make sure we're using PEP 585 and PEP 604 # @link https://github.com/frostming/fix-future-annotations - @UV_PYTHON=${UV_PYTHON} \ - ${UVX} fix-future-annotations ${fix_future_annotations_opts} src/ + @${PYTHON_BINS}/fix-future-annotations ${fix_future_annotations_opts} src/ # Here starts the frontend stuff @@ -185,10 +182,10 @@ frontend/img/copy_assets: # Here starts the "misc util targets" stuff -bin/uv: uv_version ?= 0.4.9 +bin/uv: uv_version ?= 0.5.7 bin/uv: # Install `uv` and `uvx` locally in the "bin/" folder curl -LsSf "https://astral.sh/uv/${uv_version}/install.sh" | \ - CARGO_DIST_FORCE_INSTALL_DIR="$$(pwd)" INSTALLER_NO_MODIFY_PATH=1 sh + UV_INSTALL_DIR="$$(pwd)/bin" UV_NO_MODIFY_PATH=1 sh @echo "We'll use 'bin/uv' to manage Python dependencies." .venv: ## Initialises the Python virtual environment in a ".venv" folder, via uv diff --git a/pyproject.toml b/pyproject.toml index 3e4623d..d3da29a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires-python = ">=3.11" dependencies= [ # Django doesn't follow SemVer, so we need to specify the minor version: - "Django==5.1.*", + "django==5.1.*", "gunicorn==22.*", "uvicorn[standard]==0.30.*", "uvicorn-worker==0.2.*", @@ -43,11 +43,7 @@ dev = [ "types-requests==2.*", "django-extensions==3.*", "sqlite-utils==3.*", - # N.B. As it turns out that Lichess' "Berserk" package misses some features we need, - # such as the creation of correspondence Seeks... - # And as we had to wrap evey call in an `sync_to_async` anyway, which was not great... - # We only use for its `types` module at the momemt. - "berserk==0.13.*", + "fix-future-annotations>=0.5.0", ] test = [ "pytest==8.3.*", diff --git a/uv.lock b/uv.lock index 6cc7574..6797eac 100644 --- a/uv.lock +++ b/uv.lock @@ -51,22 +51,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/4c/9aa0416a403d5cc80292cb030bcd2c918cce2755e314d8c1aa18656e1e12/Authlib-1.3.2-py2.py3-none-any.whl", hash = "sha256:ede026a95e9f5cdc2d4364a52103f5405e75aa156357e831ef2bfd0bc5094dfc", size = 225111 }, ] -[[package]] -name = "berserk" -version = "0.13.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "ndjson" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/83/c35e86fcd96cef66dac6f19dd2950e977828ac461afc4f179a41bf951bee/berserk-0.13.2.tar.gz", hash = "sha256:96c3ff3a10407842019e5e6bf3233080030419e4eba333bbd4234a86b4eff86f", size = 55539 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/3e/8b2c89d97212e5de0b433961bf76e4126c18ff3cde15e0e099993123cc5d/berserk-0.13.2-py3-none-any.whl", hash = "sha256:0f7fc40f152370924cb05a77c3f1c357a91e8ff0db60d23c14f0f16216b632a8", size = 74479 }, -] - [[package]] name = "blinker" version = "1.8.2" @@ -92,8 +76,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/0bd38d758d1afa62a5524172f0b18626bb2392d717ff94806f741fcd5ee9/Brotli-1.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19c116e796420b0cee3da1ccec3b764ed2952ccfcc298b55a10e5610ad7885f9", size = 2813051 }, { url = "https://files.pythonhosted.org/packages/14/56/48859dd5d129d7519e001f06dcfbb6e2cf6db92b2702c0c2ce7d97e086c1/Brotli-1.1.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:510b5b1bfbe20e1a7b3baf5fed9e9451873559a976c1a78eebaa3b86c57b4265", size = 2938172 }, { url = "https://files.pythonhosted.org/packages/3d/77/a236d5f8cd9e9f4348da5acc75ab032ab1ab2c03cc8f430d24eea2672888/Brotli-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a1fd8a29719ccce974d523580987b7f8229aeace506952fa9ce1d53a033873c8", size = 2933023 }, + { url = "https://files.pythonhosted.org/packages/f1/87/3b283efc0f5cb35f7f84c0c240b1e1a1003a5e47141a4881bf87c86d0ce2/Brotli-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c247dd99d39e0338a604f8c2b3bc7061d5c2e9e2ac7ba9cc1be5a69cb6cd832f", size = 2935871 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/2be4cc3e2141dc1a43ad4ca1875a72088229de38c68e842746b342667b2a/Brotli-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1b2c248cd517c222d89e74669a4adfa5577e06ab68771a529060cf5a156e9757", size = 2847784 }, + { url = "https://files.pythonhosted.org/packages/66/13/b58ddebfd35edde572ccefe6890cf7c493f0c319aad2a5badee134b4d8ec/Brotli-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a24c50840d89ded6c9a8fdc7b6ed3692ed4e86f1c4a4a938e1e92def92933e0", size = 3034905 }, + { url = "https://files.pythonhosted.org/packages/84/9c/bc96b6c7db824998a49ed3b38e441a2cae9234da6fa11f6ed17e8cf4f147/Brotli-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f31859074d57b4639318523d6ffdca586ace54271a73ad23ad021acd807eb14b", size = 2929467 }, { url = "https://files.pythonhosted.org/packages/e7/71/8f161dee223c7ff7fea9d44893fba953ce97cf2c3c33f78ba260a91bcff5/Brotli-1.1.0-cp311-cp311-win32.whl", hash = "sha256:39da8adedf6942d76dc3e46653e52df937a3c4d6d18fdc94a7c29d263b1f5b50", size = 333169 }, { url = "https://files.pythonhosted.org/packages/02/8a/fece0ee1057643cb2a5bbf59682de13f1725f8482b2c057d4e799d7ade75/Brotli-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:aac0411d20e345dc0920bdec5548e438e999ff68d77564d5e9463a7ca9d3e7b1", size = 357253 }, + { url = "https://files.pythonhosted.org/packages/5c/d0/5373ae13b93fe00095a58efcbce837fd470ca39f703a235d2a999baadfbc/Brotli-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:32d95b80260d79926f5fab3c41701dbb818fde1c9da590e77e571eefd14abe28", size = 815693 }, + { url = "https://files.pythonhosted.org/packages/8e/48/f6e1cdf86751300c288c1459724bfa6917a80e30dbfc326f92cea5d3683a/Brotli-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b760c65308ff1e462f65d69c12e4ae085cff3b332d894637f6273a12a482d09f", size = 422489 }, { url = "https://files.pythonhosted.org/packages/06/88/564958cedce636d0f1bed313381dfc4b4e3d3f6015a63dae6146e1b8c65c/Brotli-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:316cc9b17edf613ac76b1f1f305d2a748f1b976b033b049a6ecdfd5612c70409", size = 873081 }, { url = "https://files.pythonhosted.org/packages/58/79/b7026a8bb65da9a6bb7d14329fd2bd48d2b7f86d7329d5cc8ddc6a90526f/Brotli-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:caf9ee9a5775f3111642d33b86237b05808dafcd6268faa492250e9b78046eb2", size = 446244 }, { url = "https://files.pythonhosted.org/packages/e5/18/c18c32ecea41b6c0004e15606e274006366fe19436b6adccc1ae7b2e50c2/Brotli-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70051525001750221daa10907c77830bc889cb6d865cc0b813d9db7fefc21451", size = 2906505 }, @@ -104,8 +94,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/58/5c391b41ecfc4527d2cc3350719b02e87cb424ef8ba2023fb662f9bf743c/Brotli-1.1.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:4093c631e96fdd49e0377a9c167bfd75b6d0bad2ace734c6eb20b348bc3ea180", size = 2814452 }, { url = "https://files.pythonhosted.org/packages/c7/4e/91b8256dfe99c407f174924b65a01f5305e303f486cc7a2e8a5d43c8bec3/Brotli-1.1.0-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:7e4c4629ddad63006efa0ef968c8e4751c5868ff0b1c5c40f76524e894c50248", size = 2938751 }, { url = "https://files.pythonhosted.org/packages/5a/a6/e2a39a5d3b412938362bbbeba5af904092bf3f95b867b4a3eb856104074e/Brotli-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:861bf317735688269936f755fa136a99d1ed526883859f86e41a5d43c61d8966", size = 2933757 }, + { url = "https://files.pythonhosted.org/packages/13/f0/358354786280a509482e0e77c1a5459e439766597d280f28cb097642fc26/Brotli-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:87a3044c3a35055527ac75e419dfa9f4f3667a1e887ee80360589eb8c90aabb9", size = 2936146 }, + { url = "https://files.pythonhosted.org/packages/80/f7/daf538c1060d3a88266b80ecc1d1c98b79553b3f117a485653f17070ea2a/Brotli-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c5529b34c1c9d937168297f2c1fde7ebe9ebdd5e121297ff9c043bdb2ae3d6fb", size = 2848055 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0eaa0585c4077d3c2d1edf322d8e97aabf317941d3a72d7b3ad8bce004b0/Brotli-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ca63e1890ede90b2e4454f9a65135a4d387a4585ff8282bb72964fab893f2111", size = 3035102 }, + { url = "https://files.pythonhosted.org/packages/d8/63/1c1585b2aa554fe6dbce30f0c18bdbc877fa9a1bf5ff17677d9cca0ac122/Brotli-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e79e6520141d792237c70bcd7a3b122d00f2613769ae0cb61c52e89fd3443839", size = 2930029 }, { url = "https://files.pythonhosted.org/packages/5f/3b/4e3fd1893eb3bbfef8e5a80d4508bec17a57bb92d586c85c12d28666bb13/Brotli-1.1.0-cp312-cp312-win32.whl", hash = "sha256:5f4d5ea15c9382135076d2fb28dde923352fe02951e66935a9efaac8f10e81b0", size = 333276 }, { url = "https://files.pythonhosted.org/packages/3d/d5/942051b45a9e883b5b6e98c041698b1eb2012d25e5948c58d6bf85b1bb43/Brotli-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:906bc3a79de8c4ae5b86d3d75a8b77e44404b0f4261714306e3ad248d8ab0951", size = 357255 }, + { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681 }, + { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475 }, + { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173 }, + { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803 }, + { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946 }, + { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707 }, + { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231 }, + { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157 }, + { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122 }, + { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206 }, + { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804 }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517 }, ] [[package]] @@ -352,18 +358,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, ] -[[package]] -name = "deprecated" -version = "1.2.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3", size = 2974416 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c", size = 9561 }, -] - [[package]] name = "diff-match-patch" version = "20230430" @@ -397,16 +391,16 @@ wheels = [ [[package]] name = "django" -version = "5.1.1" +version = "5.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/6f/8f57ed6dc88656edd4fcb35c50dd963f3cd79303bd711fb0160fc7fd6ab7/Django-5.1.1.tar.gz", hash = "sha256:021ffb7fdab3d2d388bc8c7c2434eb9c1f6f4d09e6119010bbb1694dda286bc2", size = 10675933 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/e8/536555596dbb79f6e77418aeb40bdc1758c26725aba31919ba449e6d5e6a/Django-5.1.4.tar.gz", hash = "sha256:de450c09e91879fa5a307f696e57c851955c910a438a35e6b4c895e86bedc82a", size = 10716397 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/aa/b423e37e9ba5480d3fd1d187e3fdbd09f9f71b991468881a45413522ccd3/Django-5.1.1-py3-none-any.whl", hash = "sha256:71603f27dac22a6533fb38d83072eea9ddb4017fead6f67f2562a40402d61c3f", size = 8246418 }, + { url = "https://files.pythonhosted.org/packages/58/0b/8a4ab2c02982df4ed41e29f28f189459a7eba37899438e6bea7f39db793b/Django-5.1.4-py3-none-any.whl", hash = "sha256:236e023f021f5ce7dee5779de7b286565fdea5f4ab86bae5338e3f7b69896cf0", size = 8276471 }, ] [[package]] @@ -529,6 +523,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, ] +[[package]] +name = "fix-future-annotations" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tokenize-rt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/56/d5ebafd3f1c46b43d5edd491359a870e224613ada3d1b6358a114d68d59e/fix-future-annotations-0.5.0.tar.gz", hash = "sha256:666bf5fd09068f3403b34b2dc69844955a4a7bac4df28dd345183bd18172cbfd", size = 11261 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/eb/7f2e259f542145d7bc4f4d664b7d9dedfa9da6ac564efeac3c5e6fa2cb33/fix_future_annotations-0.5.0-py3-none-any.whl", hash = "sha256:45b5e73b36c514a23c2fbb2b7ff26448496399bda3b98a7b78a14439639f2e59", size = 10149 }, +] + [[package]] name = "flask" version = "3.0.3" @@ -968,15 +974,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] -[[package]] -name = "ndjson" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/d5/209b6ca94566f9c94c0ec41cee1681c0a3b92a306a84a9b0fcd662088dc3/ndjson-0.3.1.tar.gz", hash = "sha256:bf9746cb6bb1cb53d172cda7f154c07c786d665ff28341e4e689b796b229e5d6", size = 6448 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/c9/04ba0056011ba96a58163ebfd666d8385300bd12da1afe661a5a147758d7/ndjson-0.3.1-py2.py3-none-any.whl", hash = "sha256:839c22275e6baa3040077b83c005ac24199b94973309a8a1809be962c753a410", size = 5305 }, -] - [[package]] name = "nodeenv" version = "1.9.1" @@ -1068,8 +1065,6 @@ version = "6.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/c7/8c6872f7372eb6a6b2e4708b88419fb46b857f7a2e1892966b851cc79fc9/psutil-6.0.0.tar.gz", hash = "sha256:8faae4f310b6d969fa26ca0545338b21f73c6b15db7c4a8d934a5482faa818f2", size = 508067 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/66/78c9c3020f573c58101dc43a44f6855d01bbbd747e24da2f0c4491200ea3/psutil-6.0.0-cp27-none-win32.whl", hash = "sha256:02b69001f44cc73c1c5279d02b30a817e339ceb258ad75997325e0e6169d8b35", size = 249766 }, - { url = "https://files.pythonhosted.org/packages/e1/3f/2403aa9558bea4d3854b0e5e567bc3dd8e9fbc1fc4453c0aa9aafeb75467/psutil-6.0.0-cp27-none-win_amd64.whl", hash = "sha256:21f1fb635deccd510f69f485b87433460a603919b45e2a324ad65b0cc74f8fb1", size = 253024 }, { url = "https://files.pythonhosted.org/packages/0b/37/f8da2fbd29690b3557cca414c1949f92162981920699cd62095a984983bf/psutil-6.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c588a7e9b1173b6e866756dde596fd4cad94f9399daf99ad8c3258b3cb2b47a0", size = 250961 }, { url = "https://files.pythonhosted.org/packages/35/56/72f86175e81c656a01c4401cd3b1c923f891b31fbcebe98985894176d7c9/psutil-6.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ed2440ada7ef7d0d608f20ad89a04ec47d2d3ab7190896cd62ca5fc4fe08bf0", size = 287478 }, { url = "https://files.pythonhosted.org/packages/19/74/f59e7e0d392bc1070e9a70e2f9190d652487ac115bb16e2eff6b22ad1d24/psutil-6.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd9a97c8e94059b0ef54a7d4baf13b405011176c3b6ff257c247cae0d560ecd", size = 290455 }, @@ -1517,6 +1512,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/4d/0db5b8a613d2a59bbc29bc5bb44a2f8070eb9ceab11c50d477502a8a0092/tinycss2-1.3.0-py3-none-any.whl", hash = "sha256:54a8dbdffb334d536851be0226030e9505965bb2f30f21a4a82c55fb2a80fae7", size = 22532 }, ] +[[package]] +name = "tokenize-rt" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/0a/5854d8ced8c1e00193d1353d13db82d7f813f99bd5dcb776ce3e2a4c0d19/tokenize_rt-6.1.0.tar.gz", hash = "sha256:e8ee836616c0877ab7c7b54776d2fefcc3bde714449a206762425ae114b53c86", size = 5506 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/ba/576aac29b10dfa49a6ce650001d1bb31f81e734660555eaf144bfe5b8995/tokenize_rt-6.1.0-py2.py3-none-any.whl", hash = "sha256:d706141cdec4aa5f358945abe36b911b8cbdc844545da99e811250c0cee9b6fc", size = 6015 }, +] + [[package]] name = "tomli" version = "2.0.1" @@ -1775,35 +1779,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/d1/ab1d78bcf3e78517f4c57d34c3b349f1289afb5b2dbf46e5bf5c96932be5/whitenoise-6.5.0-py3-none-any.whl", hash = "sha256:16468e9ad2189f09f4a8c635a9031cc9bb2cdbc8e5e53365407acf99f7ade9ec", size = 19842 }, ] -[[package]] -name = "wrapt" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313 }, - { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164 }, - { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890 }, - { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118 }, - { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746 }, - { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668 }, - { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556 }, - { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712 }, - { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327 }, - { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523 }, - { url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614 }, - { url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316 }, - { url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322 }, - { url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055 }, - { url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291 }, - { url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374 }, - { url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896 }, - { url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738 }, - { url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568 }, - { url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653 }, - { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, -] - [[package]] name = "zakuchess" version = "0.1.0" @@ -1830,8 +1805,8 @@ dependencies = [ [package.optional-dependencies] dev = [ - { name = "berserk" }, { name = "django-extensions" }, + { name = "fix-future-annotations" }, { name = "ipython" }, { name = "mypy" }, { name = "pre-commit" }, @@ -1856,7 +1831,6 @@ test = [ [package.metadata] requires-dist = [ { name = "authlib", specifier = "==1.*" }, - { name = "berserk", marker = "extra == 'dev'", specifier = "==0.13.*" }, { name = "chess", specifier = "==1.*" }, { name = "dj-database-url", specifier = "==2.*" }, { name = "django", specifier = "==5.1.*" }, @@ -1867,6 +1841,7 @@ requires-dist = [ { name = "django-htmx", specifier = "==1.*" }, { name = "django-import-export", specifier = "==4.*" }, { name = "dominate", specifier = "==2.*" }, + { name = "fix-future-annotations", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "gunicorn", specifier = "==22.*" }, { name = "httpx", specifier = "==0.27.*" }, { name = "ipython", marker = "extra == 'dev'", specifier = "==8.*" }, From 2b4c1e2d8c89949be668f3e1f9b70f1eb04aa77a Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 13:19:55 +0000 Subject: [PATCH 39/42] [UI] Start using atomic components, rather than shared classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That will give us better semantics, and should make maintenance a bit easier in the long term 🤞 --- .../components/misc_ui/daily_challenge_bar.py | 89 ++++++++------- .../components/misc_ui/help.py | 23 ++-- .../components/misc_ui/status_bar.py | 12 +- .../components/pages/daily_chess_pages.py | 6 +- .../components/game_creation.py | 13 +-- .../components/misc_ui/user_profile_modal.py | 13 +-- .../components/no_linked_account.py | 13 +-- .../components/pages/lichess_pages.py | 10 +- src/apps/webui/components/atoms/__init__.py | 0 src/apps/webui/components/atoms/button.py | 104 ++++++++++++++++++ src/apps/webui/components/common_styles.py | 8 -- .../components/misc_ui/user_prefs_modal.py | 14 +-- 12 files changed, 199 insertions(+), 106 deletions(-) create mode 100644 src/apps/webui/components/atoms/__init__.py create mode 100644 src/apps/webui/components/atoms/button.py delete mode 100644 src/apps/webui/components/common_styles.py diff --git a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py index ede1405..53d3923 100644 --- a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py @@ -7,11 +7,11 @@ from django.contrib.humanize.templatetags.humanize import ordinal from django.urls import reverse -from dominate.tags import b, button, div, p +from dominate.tags import b, div, p from dominate.util import raw from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG from ...models import PlayerGameOverState @@ -22,6 +22,8 @@ ) if TYPE_CHECKING: + from collections.abc import Sequence + from dominate.tags import dom_tag from ...presenters import DailyChallengeGamePresenter @@ -161,19 +163,17 @@ def _confirmation_dialog( return div( question, div( - button( + zc_button( "Confirm", - " ", - ICON_SVG_CONFIRM, - cls=common_styles.BUTTON_CONFIRM_CLASSES, - **htmx_attributes_confirm, + button_type="confirm", + svg_icon=ICON_SVG_CONFIRM, + htmx_attributes=htmx_attributes_confirm, ), - button( + zc_button( "Cancel", - " ", - ICON_SVG_CANCEL, - cls=common_styles.BUTTON_CANCEL_CLASSES, - **htmx_attributes_cancel, + svg_icon=ICON_SVG_CANCEL, + button_type="cancel", + htmx_attributes=htmx_attributes_cancel, ), cls="text-center", ), @@ -246,15 +246,15 @@ def _undo_button( additional_attributes = {"disabled": True} if not can_undo else {} classes = _button_classes(disabled=not can_undo) - return button( + return zc_button( "Undo", - " ", - ICON_SVG_UNDO, - cls=classes, + svg_icon=ICON_SVG_UNDO, + button_type="action", title="Undo your last move", - id=f"chess-board-undo-daily-challenge-{board_id}", - **additional_attributes, - **htmx_attributes, + id_=f"chess-board-undo-daily-challenge-{board_id}", + htmx_attributes=htmx_attributes, + additional_classes=classes, + additional_attributes=additional_attributes, ) @@ -284,15 +284,15 @@ def _retry_button( additional_attributes = {"disabled": True} if not can_retry else {} classes = _button_classes(disabled=not can_retry) - return button( + return zc_button( "Retry", - " ", - ICON_SVG_RESTART, - cls=classes, + svg_icon=ICON_SVG_RESTART, + button_type="action", + additional_classes=classes, title="Try this daily challenge again, from the beginning", - id=f"chess-board-restart-daily-challenge-{board_id}", - **additional_attributes, - **htmx_attributes, + id_=f"chess-board-restart-daily-challenge-{board_id}", + additional_attributes=additional_attributes, + htmx_attributes=htmx_attributes, ) @@ -329,14 +329,14 @@ def _see_solution_button( classes = _button_classes(full_width=full_width) - return button( + return zc_button( "See solution", - " ", - ICON_SVG_LIGHT_BULB, - cls=classes, + svg_icon=ICON_SVG_LIGHT_BULB, + button_type="action", + additional_classes=classes, title=title, - id=f"chess-board-restart-daily-challenge-{board_id}", - **htmx_attributes, + id_=f"chess-board-restart-daily-challenge-{board_id}", + htmx_attributes=htmx_attributes, ) @@ -349,25 +349,24 @@ def _user_prefs_button(board_id: str) -> dom_tag: classes = _button_classes() - return button( + return zc_button( "Preferences", - " ", - ICON_SVG_COG, - cls=classes, + svg_icon=ICON_SVG_COG, + button_type="action", + additional_classes=classes, title="Edit preferences", - id=f"chess-board-preferences-daily-challenge-{board_id}", - **htmx_attributes, + id_=f"chess-board-preferences-daily-challenge-{board_id}", + htmx_attributes=htmx_attributes, ) @functools.cache -def _button_classes(*, full_width: bool = True, disabled: bool = False) -> str: - return " ".join( - ( - common_styles.BUTTON_CLASSES, - ("w-full" if full_width else ""), - (" opacity-50 cursor-not-allowed" if disabled else ""), - ) +def _button_classes( + *, full_width: bool = True, disabled: bool = False +) -> Sequence[str]: + return ( + ("w-full" if full_width else ""), + (" opacity-50 cursor-not-allowed" if disabled else ""), ) diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index 914b85d..f941517 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -7,7 +7,7 @@ from dominate.tags import div, h4, p, span from dominate.util import raw -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button from apps.webui.components.chess_units import ( CHARACTER_TYPE_TIP, chess_status_bar_tip, @@ -62,10 +62,11 @@ def help_content( div( raw("You can restart from the beginning at any time, "), "by clicking the ", - span( + zc_button( "Retry", - ICON_SVG_RESTART, - cls=f"{common_styles.BUTTON_CLASSES.replace(common_styles.BUTTON_BASE_HOVER_TEXT_COLOR, '')} !mx-0", + svg_icon=ICON_SVG_RESTART, + button_type="action", + is_a_help_for_actual_button=True, ), " button.", cls=f"{spacing}", @@ -73,10 +74,11 @@ def help_content( div( "If you can't solve today's challenge ", raw("you can decide to see a solution, by clicking the "), - span( + zc_button( "See solution", - ICON_SVG_LIGHT_BULB, - cls=f"{common_styles.BUTTON_CLASSES} !inline-block !mx-0", + svg_icon=ICON_SVG_LIGHT_BULB, + button_type="action", + is_a_help_for_actual_button=True, ), " button.", cls=f"{spacing}", @@ -84,10 +86,11 @@ def help_content( div( raw("You can customise some game settings"), " - such as the speed of the game or the appearance of the board - via the ", - span( + zc_button( "Options", - ICON_SVG_COG, - cls=f"{common_styles.BUTTON_CLASSES} !inline-block !mx-0", + svg_icon=ICON_SVG_COG, + button_type="action", + is_a_help_for_actual_button=True, ), " button.", cls=f"{spacing}", diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/misc_ui/status_bar.py index 4bb3f99..0f9e73f 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/misc_ui/status_bar.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from dominate.tags import b, button, div, p +from dominate.tags import b, div, p from dominate.util import raw from apps.chess.chess_helpers import ( @@ -15,7 +15,7 @@ help_content, unit_display_container, ) -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button from apps.webui.components.chess_units import ( character_type_tip, chess_unit_symbol_display, @@ -48,10 +48,12 @@ def status_bar( factions=game_presenter.factions, ), div( - button( + zc_button( "⇧ Scroll up to the board", - cls=common_styles.BUTTON_CLASSES, - onclick="""window.scrollTo({ top: 0, behavior: "smooth" })""", + button_type="action", + additional_attributes={ + "onclick": """"window.scrollTo({ top: 0, behavior: "smooth" })""" + }, ), cls="w-full flex justify-center", ), diff --git a/src/apps/daily_challenge/components/pages/daily_chess_pages.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py index 6056bd2..0daac12 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess_pages.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -20,8 +20,8 @@ reset_chess_engine_worker, speech_bubble_container, ) +from apps.webui.components.atoms.button import zc_header_icon_button from apps.webui.components.layout import page -from apps.webui.components.misc_ui.header import header_button from apps.webui.components.misc_ui.svg_icons import ICON_SVG_HELP from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button @@ -128,7 +128,7 @@ def _stats_button() -> dom_tag: "data_hx_swap": "outerHTML", } - return header_button( + return zc_header_icon_button( icon=ICON_SVG_STATS, title="Visualise your stats for daily challenges", id_="stats-button", @@ -143,7 +143,7 @@ def _help_button() -> dom_tag: "data_hx_swap": "outerHTML", } - return header_button( + return zc_header_icon_button( icon=ICON_SVG_HELP, title="How to play", id_="help-button", diff --git a/src/apps/lichess_bridge/components/game_creation.py b/src/apps/lichess_bridge/components/game_creation.py index 788b11f..f0e27c6 100644 --- a/src/apps/lichess_bridge/components/game_creation.py +++ b/src/apps/lichess_bridge/components/game_creation.py @@ -3,11 +3,11 @@ from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import button, div, fieldset, form, input_, label, legend, p +from dominate.tags import div, fieldset, form, input_, label, legend, p from apps.lichess_bridge.components.svg_icons import ICON_SVG_CREATE from apps.lichess_bridge.models import LichessCorrespondenceGameDaysChoice -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button from apps.webui.components.forms_common import csrf_hidden_input if TYPE_CHECKING: @@ -50,12 +50,11 @@ def game_creation_form(*, request: HttpRequest, form_errors: dict) -> form: cls="mb-8", ), div( - button( + zc_button( "Create", - " ", - ICON_SVG_CREATE, - type="submit", - cls=common_styles.BUTTON_CLASSES, + svg_icon=ICON_SVG_CREATE, + button_type="action", + html_type="submit", ), cls="text-center", ), diff --git a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py index e33ca68..9ff150d 100644 --- a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py +++ b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py @@ -3,10 +3,10 @@ from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import button, div, form, h3, h4, p, span +from dominate.tags import div, form, h3, h4, p, span from apps.chess.components.misc_ui import modal_container -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button from apps.webui.components.forms_common import csrf_hidden_input from ..svg_icons import ICON_SVG_LOG_OUT, ICON_SVG_USER @@ -56,12 +56,11 @@ def _user_profile_form(request: HttpRequest, me: LichessAccountInformation) -> f cls=f"{spacing} text-center", ), p( - button( + zc_button( "Disconnect Lichess account", - " ", - ICON_SVG_LOG_OUT, - type="submit", - cls=common_styles.BUTTON_CANCEL_CLASSES, + svg_icon=ICON_SVG_LOG_OUT, + button_type="action", + html_type="submit", ), cls=f"{spacing} text-center", ), diff --git a/src/apps/lichess_bridge/components/no_linked_account.py b/src/apps/lichess_bridge/components/no_linked_account.py index 96e54fa..8124987 100644 --- a/src/apps/lichess_bridge/components/no_linked_account.py +++ b/src/apps/lichess_bridge/components/no_linked_account.py @@ -3,11 +3,11 @@ from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import b, br, button, div, form, p +from dominate.tags import b, br, div, form, p from apps.chess.models import GameFactions from apps.lichess_bridge.components.svg_icons import ICON_SVG_LOG_IN -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button from apps.webui.components.chess_units import unit_display_container from apps.webui.components.forms_common import csrf_hidden_input @@ -67,12 +67,11 @@ def no_linked_account_content(request: HttpRequest) -> dom_tag: cls="mb-4 text-center font-bold", ), p( - button( + zc_button( "Log in via Lichess", - " ", - ICON_SVG_LOG_IN, - type="submit", - cls=common_styles.BUTTON_CLASSES, + svg_icon=ICON_SVG_LOG_IN, + html_type="submit", + button_type="action", ), cls="mb-4 text-center", ), diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 940961d..7042674 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -5,7 +5,6 @@ from django.conf import settings from django.urls import reverse from dominate.tags import ( - a, div, h3, p, @@ -20,9 +19,8 @@ chess_pieces, ) from apps.chess.components.misc_ui import speech_bubble_container -from apps.webui.components import common_styles +from apps.webui.components.atoms.button import zc_button, zc_header_icon_button from apps.webui.components.layout import page -from apps.webui.components.misc_ui.header import header_button from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button from ..game_creation import game_creation_form @@ -84,10 +82,10 @@ def lichess_my_current_games_list_page( ), lichess_ongoing_games(ongoing_games), p( - a( + zc_button( "Create a new game", href=reverse("lichess_bridge:create_game"), - cls=common_styles.BUTTON_CLASSES, + button_type="action", ), cls="my-8 text-center text-slate-50", ), @@ -221,7 +219,7 @@ def _user_account_button() -> dom_tag: "data_hx_swap": "outerHTML", } - return header_button( + return zc_header_icon_button( icon=ICON_SVG_USER, title="Manage your Lichess account", id_="stats-button", diff --git a/src/apps/webui/components/atoms/__init__.py b/src/apps/webui/components/atoms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/webui/components/atoms/button.py b/src/apps/webui/components/atoms/button.py new file mode 100644 index 0000000..6530c33 --- /dev/null +++ b/src/apps/webui/components/atoms/button.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from dominate.dom_tag import dom_tag +from dominate.tags import a, button, span + +if TYPE_CHECKING: + from collections.abc import Sequence + + from dominate.tags import dom_tag + +ButtonType = Literal["action", "confirm", "cancel"] + +_BUTTON_BASE_BG_COLOR, _BUTTON_BASE_TEXT_COLOR = "bg-rose-600", "text-slate-200" +_BUTTON_BASE_HOVER_TEXT_COLOR = "hover:text-stone-100" +_BUTTON_CLASSES = ( + "inline-block py-1 px-3 rounded-md font-bold whitespace-nowrap " + f"{_BUTTON_BASE_TEXT_COLOR} {_BUTTON_BASE_BG_COLOR} {_BUTTON_BASE_HOVER_TEXT_COLOR}" +) +_BUTTON_CONFIRM_CLASSES = _BUTTON_CLASSES.replace(_BUTTON_BASE_BG_COLOR, "bg-lime-700") +_BUTTON_CANCEL_CLASSES = _BUTTON_CLASSES.replace(_BUTTON_BASE_BG_COLOR, "bg-indigo-500") + +_BUTTON_TYPE_TO_CLASS_MAPPING: dict[ButtonType, str] = { + "action": _BUTTON_CLASSES, + "confirm": _BUTTON_CONFIRM_CLASSES, + "cancel": _BUTTON_CANCEL_CLASSES, +} + + +def zc_button( + label: str, + *, + button_type: ButtonType, + svg_icon: str | None = None, + href: str | None = None, # if href is not None, the button will be an HTML anchor + id_: str | None = None, + title: str | None = None, + html_type: str | None = None, + additional_classes: Sequence[str] | None = None, + additional_attributes: dict | None = None, + htmx_attributes: dict | None = None, + is_a_help_for_actual_button: bool = False, +) -> dom_tag: + """A 'zakuchess' (`zc_*`) button.""" + + if is_a_help_for_actual_button and htmx_attributes is not None: + raise ValueError( + "Elements that are not actual buttons but " + "an help for them should not have htmx attributes" + ) + + children: list[str] = [label] + if svg_icon: + children.extend((" ", svg_icon)) + + classes: list[str] = [_BUTTON_TYPE_TO_CLASS_MAPPING[button_type]] + if additional_classes: + classes.extend(additional_classes) + + attributes: dict = { + **(additional_attributes or {}), + **(htmx_attributes or {}), + } + if id_: + attributes["id"] = id_ + if title: + attributes["title"] = title + if html_type: + attributes["type"] = html_type + + if is_a_help_for_actual_button: + classes.extend(("!inline-block", "!mx-0")) + return span( + *children, + cls=" ".join(classes), + **attributes, + ) + + if href: + return a( + *children, + href=href, + cls=" ".join(classes), + **attributes, + ) + + return button( + *children, + cls=" ".join(classes), + **attributes, + ) + + +def zc_header_icon_button( + *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] +) -> dom_tag: + return button( + icon, + cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", + title=title, + id=id_, + **htmx_attributes, + ) diff --git a/src/apps/webui/components/common_styles.py b/src/apps/webui/components/common_styles.py deleted file mode 100644 index b9fb2f9..0000000 --- a/src/apps/webui/components/common_styles.py +++ /dev/null @@ -1,8 +0,0 @@ -BUTTON_BASE_BG_COLOR, BUTTON_BASE_TEXT_COLOR = "bg-rose-600", "text-slate-200" -BUTTON_BASE_HOVER_TEXT_COLOR = "hover:text-stone-100" -BUTTON_CLASSES = ( - "inline-block py-1 px-3 rounded-md font-bold whitespace-nowrap " - f"{BUTTON_BASE_TEXT_COLOR} {BUTTON_BASE_BG_COLOR} {BUTTON_BASE_HOVER_TEXT_COLOR}" -) -BUTTON_CONFIRM_CLASSES = BUTTON_CLASSES.replace(BUTTON_BASE_BG_COLOR, "bg-lime-700") -BUTTON_CANCEL_CLASSES = BUTTON_CLASSES.replace(BUTTON_BASE_BG_COLOR, "bg-indigo-500") diff --git a/src/apps/webui/components/misc_ui/user_prefs_modal.py b/src/apps/webui/components/misc_ui/user_prefs_modal.py index 64fcbff..9e60226 100644 --- a/src/apps/webui/components/misc_ui/user_prefs_modal.py +++ b/src/apps/webui/components/misc_ui/user_prefs_modal.py @@ -3,14 +3,13 @@ from typing import TYPE_CHECKING from django.urls import reverse -from dominate.tags import button, div, fieldset, form, h3, h4, input_, label, legend +from dominate.tags import div, fieldset, form, h3, h4, input_, label, legend from apps.chess.components.misc_ui import modal_container from apps.chess.components.svg_icons import ICON_SVG_CONFIRM from apps.chess.models import UserPrefsBoardTextureChoices, UserPrefsGameSpeedChoices -from .. import common_styles -from .header import header_button +from ..atoms.button import zc_button, zc_header_icon_button from .svg_icons import ICON_SVG_COG if TYPE_CHECKING: @@ -32,7 +31,7 @@ def user_prefs_button() -> dom_tag: "data_hx_swap": "outerHTML", } - return header_button( + return zc_header_icon_button( icon=ICON_SVG_COG, title="Edit preferences", id_="user-prefs-button", @@ -83,11 +82,10 @@ def _user_prefs_form(user_prefs: UserPrefs) -> dom_tag: ) submit_button = ( - button( + zc_button( "Save preferences", - " ", - ICON_SVG_CONFIRM, - cls=common_styles.BUTTON_CONFIRM_CLASSES, + svg_icon=ICON_SVG_CONFIRM, + button_type="confirm", ), ) From aa210eb46a4d0165b8bc672b34afde5b90f3f2a6 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 13:22:14 +0000 Subject: [PATCH 40/42] [deps] Prevent `uv` from building a "zakuchess.egg-info" every time we install the project in editable mode --- .github/workflows/test-suite.yml | 2 +- Makefile | 3 ++- src/apps/webui/components/misc_ui/header.py | 20 -------------------- 3 files changed, 3 insertions(+), 22 deletions(-) delete mode 100644 src/apps/webui/components/misc_ui/header.py diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 48c91f3..0da555f 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -32,7 +32,7 @@ jobs: - name: "Install dependencies via uv" run: uv sync --all-extras - name: "Install the project in 'editable' mode" - run: uv pip install -e . + run: uv pip install --no-build -e . # Code quality checks - name: "Run linting checks: Ruff checker" diff --git a/Makefile b/Makefile index e58e1c9..df7cc25 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ install: backend/install frontend/install ## Install the Python and frontend dep dev: .env.local db.sqlite3 dev: ## Start Django in "development" mode, as well as our frontend assets compilers in "watch" mode @${SUB_MAKE} frontend/img + ${UV} pip install --no-build -e . @./node_modules/.bin/concurrently --names "django,css,js" --prefix-colors "blue,yellow,green" \ "${SUB_MAKE} backend/watch" \ "${SUB_MAKE} frontend/css/watch" \ @@ -36,7 +37,7 @@ backend/install: bin/uv .venv ## Install the Python dependencies (via uv) and in # Install Python dependencies: ${UV} sync ${uv_sync_opts} # Install the project in editable mode, so we don't have to add "src/" to the Python path: - ${UV} pip install -e . + ${UV} pip install --no-build -e . # Install pre-commit hooks: ${PYTHON_BINS}/pre-commit install # Create a shim for Black (actually using Ruff), so the IDE can use it: diff --git a/src/apps/webui/components/misc_ui/header.py b/src/apps/webui/components/misc_ui/header.py deleted file mode 100644 index fc27705..0000000 --- a/src/apps/webui/components/misc_ui/header.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from dominate.tags import button - -if TYPE_CHECKING: - from dominate.tags import dom_tag - - -def header_button( - *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] -) -> dom_tag: - return button( - icon, - cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", - title=title, - id=id_, - **htmx_attributes, - ) From ec961d8ed2008e54c6a1150fb97bde88f852d681 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 15:44:12 +0000 Subject: [PATCH 41/42] [UI] Now that we have "atoms", we can also start using "molecule" components This should lead to better code overall, with the extra benefit of preparing the ground for the Lichess UI --- .../components/companion_bars/__init__.py | 0 .../bottom_companion_bar.py} | 22 +-- .../top_companion_bar.py} | 137 +++++++----------- .../components/misc_ui/help.py | 2 +- .../components/pages/daily_chess_pages.py | 14 +- src/apps/daily_challenge/views.py | 49 ++----- .../components/game_creation.py | 2 +- .../components/misc_ui/user_profile_modal.py | 2 +- .../components/no_linked_account.py | 2 +- .../components/pages/lichess_pages.py | 2 +- .../atoms/{button.py => buttons.py} | 22 +-- .../components/misc_ui/user_prefs_modal.py | 2 +- .../webui/components/molecules/__init__.py | 0 .../molecules/chess_arena_companion_bars.py | 76 ++++++++++ 14 files changed, 178 insertions(+), 154 deletions(-) create mode 100644 src/apps/daily_challenge/components/companion_bars/__init__.py rename src/apps/daily_challenge/components/{misc_ui/status_bar.py => companion_bars/bottom_companion_bar.py} (90%) rename src/apps/daily_challenge/components/{misc_ui/daily_challenge_bar.py => companion_bars/top_companion_bar.py} (75%) rename src/apps/webui/components/atoms/{button.py => buttons.py} (83%) create mode 100644 src/apps/webui/components/molecules/__init__.py create mode 100644 src/apps/webui/components/molecules/chess_arena_companion_bars.py diff --git a/src/apps/daily_challenge/components/companion_bars/__init__.py b/src/apps/daily_challenge/components/companion_bars/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/daily_challenge/components/misc_ui/status_bar.py b/src/apps/daily_challenge/components/companion_bars/bottom_companion_bar.py similarity index 90% rename from src/apps/daily_challenge/components/misc_ui/status_bar.py rename to src/apps/daily_challenge/components/companion_bars/bottom_companion_bar.py index 0f9e73f..8b209ef 100644 --- a/src/apps/daily_challenge/components/misc_ui/status_bar.py +++ b/src/apps/daily_challenge/components/companion_bars/bottom_companion_bar.py @@ -15,11 +15,12 @@ help_content, unit_display_container, ) -from apps.webui.components.atoms.button import zc_button +from apps.webui.components.atoms.buttons import zc_button from apps.webui.components.chess_units import ( character_type_tip, chess_unit_symbol_display, ) +from apps.webui.components.molecules.chess_arena_companion_bars import companion_bar if TYPE_CHECKING: from dominate.tags import dom_tag @@ -28,11 +29,12 @@ def status_bar( - *, game_presenter: DailyChallengeGamePresenter, board_id: str, **extra_attrs: str + *, + game_presenter: DailyChallengeGamePresenter, + board_id: str, + htmx_attrs: dict[str, str] | None = None, ) -> dom_tag: - from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES - - # TODO: split this function into smaller ones + # TODO: split this function into smaller ones? inner_content: dom_tag = div("status to implement") @@ -51,7 +53,7 @@ def status_bar( zc_button( "⇧ Scroll up to the board", button_type="action", - additional_attributes={ + extra_attrs={ "onclick": """"window.scrollTo({ top: 0, behavior: "smooth" })""" }, ), @@ -93,11 +95,11 @@ def status_bar( case "waiting_for_bot_turn": inner_content = _chess_status_bar_waiting_for_bot_turn(game_presenter) - return div( + return companion_bar( inner_content, - id=f"chess-board-status-bar-{board_id}", - cls=f"min-h-[4rem] flex items-center {INFO_BARS_COMMON_CLASSES} border-t-0 rounded-b-md", - **extra_attrs, + id_=f"chess-board-status-bar-{board_id}", + position="bottom", + htmx_attrs=htmx_attrs, ) diff --git a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py b/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py similarity index 75% rename from src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py rename to src/apps/daily_challenge/components/companion_bars/top_companion_bar.py index 53d3923..dbeefb6 100644 --- a/src/apps/daily_challenge/components/misc_ui/daily_challenge_bar.py +++ b/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py @@ -10,51 +10,47 @@ from dominate.tags import b, div, p from dominate.util import raw -from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM -from apps.webui.components.atoms.button import zc_button -from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG - -from ...models import PlayerGameOverState -from .svg_icons import ( +from apps.daily_challenge.components.misc_ui.svg_icons import ( ICON_SVG_LIGHT_BULB, ICON_SVG_RESTART, ICON_SVG_UNDO, ) +from apps.daily_challenge.models import PlayerGameOverState +from apps.webui.components.atoms.buttons import zc_button +from apps.webui.components.misc_ui.svg_icons import ICON_SVG_COG +from apps.webui.components.molecules.chess_arena_companion_bars import ( + companion_bar, + confirmation_dialog_bar, +) if TYPE_CHECKING: from collections.abc import Sequence from dominate.tags import dom_tag - from ...presenters import DailyChallengeGamePresenter + from apps.daily_challenge.presenters import DailyChallengeGamePresenter def daily_challenge_bar( *, - game_presenter: DailyChallengeGamePresenter | None, + game_presenter: DailyChallengeGamePresenter, board_id: str, - inner_content: dom_tag | None = None, - **extra_attrs: str, + htmx_attrs: dict[str, str] | None = None, ) -> dom_tag: - from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES - - if not inner_content: - assert game_presenter is not None - inner_content = _current_state_display( - game_presenter=game_presenter, board_id=board_id - ) + inner_content = _current_state_display( + game_presenter=game_presenter, board_id=board_id + ) - return div( + return companion_bar( inner_content, - id=f"chess-board-daily-challenge-bar-{board_id}", - cls=f"min-h-[4rem] flex items-center justify-center {INFO_BARS_COMMON_CLASSES} " - "border-t-0 xl:border-2 xl:rounded-t-md", - **extra_attrs, + id_=f"chess-board-daily-challenge-bar-{board_id}", + position="top", + htmx_attrs=htmx_attrs, ) -def retry_confirmation_display(*, board_id: str) -> dom_tag: - htmx_attributes_confirm = { +def retry_confirmation_dialog_bar(*, board_id: str) -> dom_tag: + htmx_attrs_confirm = { "data_hx_post": "".join( ( reverse("daily_challenge:htmx_restart_daily_challenge_do"), @@ -65,7 +61,7 @@ def retry_confirmation_display(*, board_id: str) -> dom_tag: "data_hx_target": f"#chess-board-pieces-{board_id}", "data_hx_swap": "outerHTML", } - htmx_attributes_cancel = { + htmx_attrs_cancel = { "data_hx_get": "".join( ( reverse("daily_challenge:htmx_game_no_selection"), @@ -77,15 +73,16 @@ def retry_confirmation_display(*, board_id: str) -> dom_tag: "data_hx_swap": "outerHTML", } - return _confirmation_dialog( + return confirmation_dialog_bar( question=div("Retry today's challenge from the start?", cls="text-center"), - htmx_attributes_confirm=htmx_attributes_confirm, - htmx_attributes_cancel=htmx_attributes_cancel, + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-daily-challenge-bar-{board_id}", ) -def undo_confirmation_display(*, board_id: str) -> dom_tag: - htmx_attributes_confirm = { +def undo_confirmation_dialog_bar(*, board_id: str) -> dom_tag: + htmx_attrs_confirm = { "data_hx_post": "".join( ( reverse("daily_challenge:htmx_undo_last_move_do"), @@ -96,7 +93,7 @@ def undo_confirmation_display(*, board_id: str) -> dom_tag: "data_hx_target": f"#chess-board-pieces-{board_id}", "data_hx_swap": "outerHTML", } - htmx_attributes_cancel = { + htmx_attrs_cancel = { "data_hx_get": "".join( ( reverse("daily_challenge:htmx_game_no_selection"), @@ -108,19 +105,20 @@ def undo_confirmation_display(*, board_id: str) -> dom_tag: "data_hx_swap": "outerHTML", } - return _confirmation_dialog( + return confirmation_dialog_bar( question=div( p("Undo your last move?"), b("⚠️ You will not be able to undo a move for today's challenge again."), cls="text-center", ), - htmx_attributes_confirm=htmx_attributes_confirm, - htmx_attributes_cancel=htmx_attributes_cancel, + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-daily-challenge-bar-{board_id}", ) -def see_solution_confirmation_display(*, board_id: str) -> dom_tag: - htmx_attributes_confirm = { +def see_solution_confirmation_dialog_bar(*, board_id: str) -> dom_tag: + htmx_attrs_confirm = { "data_hx_post": "".join( ( reverse("daily_challenge:htmx_see_daily_challenge_solution_do"), @@ -131,7 +129,7 @@ def see_solution_confirmation_display(*, board_id: str) -> dom_tag: "data_hx_target": f"#chess-board-pieces-{board_id}", "data_hx_swap": "outerHTML", } - htmx_attributes_cancel = { + htmx_attrs_cancel = { "data_hx_get": "".join( ( reverse("daily_challenge:htmx_game_no_selection"), @@ -143,40 +141,15 @@ def see_solution_confirmation_display(*, board_id: str) -> dom_tag: "data_hx_swap": "outerHTML", } - return _confirmation_dialog( + return confirmation_dialog_bar( question=div( p("Give up for today, and see a solution?"), b("⚠️ You will not be able to try today's challenge again."), cls="text-center", ), - htmx_attributes_confirm=htmx_attributes_confirm, - htmx_attributes_cancel=htmx_attributes_cancel, - ) - - -def _confirmation_dialog( - *, - question: dom_tag, - htmx_attributes_confirm: dict[str, str], - htmx_attributes_cancel: dict[str, str], -) -> dom_tag: - return div( - question, - div( - zc_button( - "Confirm", - button_type="confirm", - svg_icon=ICON_SVG_CONFIRM, - htmx_attributes=htmx_attributes_confirm, - ), - zc_button( - "Cancel", - svg_icon=ICON_SVG_CANCEL, - button_type="cancel", - htmx_attributes=htmx_attributes_cancel, - ), - cls="text-center", - ), + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-daily-challenge-bar-{board_id}", ) @@ -227,7 +200,7 @@ def _undo_button( and game_state.game_over != PlayerGameOverState.WON ) - htmx_attributes = ( + htmx_attrs = ( { "data_hx_post": "".join( ( @@ -243,7 +216,7 @@ def _undo_button( else {} ) - additional_attributes = {"disabled": True} if not can_undo else {} + additional_attrs = {"disabled": True} if not can_undo else {} classes = _button_classes(disabled=not can_undo) return zc_button( @@ -252,9 +225,9 @@ def _undo_button( button_type="action", title="Undo your last move", id_=f"chess-board-undo-daily-challenge-{board_id}", - htmx_attributes=htmx_attributes, - additional_classes=classes, - additional_attributes=additional_attributes, + htmx_attrs=htmx_attrs, + extra_classes=classes, + extra_attrs=additional_attrs, ) @@ -263,7 +236,7 @@ def _retry_button( ) -> dom_tag: can_retry: bool = game_presenter.game_state.current_attempt_turns_counter > 0 - htmx_attributes = ( + htmx_attrs = ( { "data_hx_post": "".join( ( @@ -281,18 +254,18 @@ def _retry_button( else {} ) - additional_attributes = {"disabled": True} if not can_retry else {} + additional_attrs = {"disabled": True} if not can_retry else {} classes = _button_classes(disabled=not can_retry) return zc_button( "Retry", svg_icon=ICON_SVG_RESTART, button_type="action", - additional_classes=classes, + extra_classes=classes, title="Try this daily challenge again, from the beginning", id_=f"chess-board-restart-daily-challenge-{board_id}", - additional_attributes=additional_attributes, - htmx_attributes=htmx_attributes, + extra_attrs=additional_attrs, + htmx_attrs=htmx_attrs, ) @@ -315,7 +288,7 @@ def _see_solution_button( else "Give up for today, and see a solution" ) - htmx_attributes = { + htmx_attrs = { "data_hx_post": "".join( ( reverse(target_route), @@ -333,15 +306,15 @@ def _see_solution_button( "See solution", svg_icon=ICON_SVG_LIGHT_BULB, button_type="action", - additional_classes=classes, + extra_classes=classes, title=title, id_=f"chess-board-restart-daily-challenge-{board_id}", - htmx_attributes=htmx_attributes, + htmx_attrs=htmx_attrs, ) def _user_prefs_button(board_id: str) -> dom_tag: - htmx_attributes = { + htmx_attrs = { "data_hx_get": reverse("webui:htmx_modal_user_prefs"), "data_hx_target": "#modals-container", "data_hx_swap": "outerHTML", @@ -353,10 +326,10 @@ def _user_prefs_button(board_id: str) -> dom_tag: "Preferences", svg_icon=ICON_SVG_COG, button_type="action", - additional_classes=classes, + extra_classes=classes, title="Edit preferences", id_=f"chess-board-preferences-daily-challenge-{board_id}", - htmx_attributes=htmx_attributes, + htmx_attrs=htmx_attrs, ) diff --git a/src/apps/daily_challenge/components/misc_ui/help.py b/src/apps/daily_challenge/components/misc_ui/help.py index f941517..b792954 100644 --- a/src/apps/daily_challenge/components/misc_ui/help.py +++ b/src/apps/daily_challenge/components/misc_ui/help.py @@ -7,7 +7,7 @@ from dominate.tags import div, h4, p, span from dominate.util import raw -from apps.webui.components.atoms.button import zc_button +from apps.webui.components.atoms.buttons import zc_button from apps.webui.components.chess_units import ( CHARACTER_TYPE_TIP, chess_status_bar_tip, diff --git a/src/apps/daily_challenge/components/pages/daily_chess_pages.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py index 0daac12..585ef7b 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess_pages.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -20,13 +20,17 @@ reset_chess_engine_worker, speech_bubble_container, ) -from apps.webui.components.atoms.button import zc_header_icon_button +from apps.daily_challenge.components.companion_bars.bottom_companion_bar import ( + status_bar, +) +from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + daily_challenge_bar, +) +from apps.webui.components.atoms.buttons import zc_header_icon_button from apps.webui.components.layout import page from apps.webui.components.misc_ui.svg_icons import ICON_SVG_HELP from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button -from ..misc_ui.daily_challenge_bar import daily_challenge_bar -from ..misc_ui.status_bar import status_bar from ..misc_ui.svg_icons import ICON_SVG_STATS if TYPE_CHECKING: @@ -96,12 +100,12 @@ def daily_challenge_moving_parts_fragment( daily_challenge_bar( game_presenter=game_presenter, board_id=board_id, - data_hx_swap_oob="outerHTML", + htmx_attrs={"data_hx_swap_oob": "outerHTML"}, ), status_bar( game_presenter=game_presenter, board_id=board_id, - data_hx_swap_oob="outerHTML", + htmx_attrs={"data_hx_swap_oob": "outerHTML"}, ), div( speech_bubble_container( diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index 7ac960e..6feab8c 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -256,22 +256,11 @@ def htmx_daily_challenge_help_modal( def htmx_restart_daily_challenge_ask_confirmation( request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from .components.misc_ui.daily_challenge_bar import ( - daily_challenge_bar, - retry_confirmation_display, + from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + retry_confirmation_dialog_bar, ) - daily_challenge_bar_inner_content = retry_confirmation_display( - board_id=ctx.board_id - ) - - return HttpResponse( - daily_challenge_bar( - game_presenter=None, - inner_content=daily_challenge_bar_inner_content, - board_id=ctx.board_id, - ) - ) + return HttpResponse(retry_confirmation_dialog_bar(board_id=ctx.board_id)) @require_POST @@ -317,20 +306,11 @@ def htmx_restart_daily_challenge_do( def htmx_undo_last_move_ask_confirmation( request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from .components.misc_ui.daily_challenge_bar import ( - daily_challenge_bar, - undo_confirmation_display, + from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + undo_confirmation_dialog_bar, ) - daily_challenge_bar_inner_content = undo_confirmation_display(board_id=ctx.board_id) - - return HttpResponse( - daily_challenge_bar( - game_presenter=None, - inner_content=daily_challenge_bar_inner_content, - board_id=ctx.board_id, - ) - ) + return HttpResponse(undo_confirmation_dialog_bar(board_id=ctx.board_id)) @require_POST @@ -368,22 +348,11 @@ def htmx_undo_last_move_do(request: HttpRequest, *, ctx: GameContext) -> HttpRes def htmx_see_daily_challenge_solution_ask_confirmation( request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from .components.misc_ui.daily_challenge_bar import ( - daily_challenge_bar, - see_solution_confirmation_display, - ) - - daily_challenge_bar_inner_content = see_solution_confirmation_display( - board_id=ctx.board_id + from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + see_solution_confirmation_dialog_bar, ) - return HttpResponse( - daily_challenge_bar( - game_presenter=None, - inner_content=daily_challenge_bar_inner_content, - board_id=ctx.board_id, - ) - ) + return HttpResponse(see_solution_confirmation_dialog_bar(board_id=ctx.board_id)) @require_POST diff --git a/src/apps/lichess_bridge/components/game_creation.py b/src/apps/lichess_bridge/components/game_creation.py index f0e27c6..8a69f14 100644 --- a/src/apps/lichess_bridge/components/game_creation.py +++ b/src/apps/lichess_bridge/components/game_creation.py @@ -7,7 +7,7 @@ from apps.lichess_bridge.components.svg_icons import ICON_SVG_CREATE from apps.lichess_bridge.models import LichessCorrespondenceGameDaysChoice -from apps.webui.components.atoms.button import zc_button +from apps.webui.components.atoms.buttons import zc_button from apps.webui.components.forms_common import csrf_hidden_input if TYPE_CHECKING: diff --git a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py index 9ff150d..fd27c90 100644 --- a/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py +++ b/src/apps/lichess_bridge/components/misc_ui/user_profile_modal.py @@ -6,7 +6,7 @@ from dominate.tags import div, form, h3, h4, p, span from apps.chess.components.misc_ui import modal_container -from apps.webui.components.atoms.button import zc_button +from apps.webui.components.atoms.buttons import zc_button from apps.webui.components.forms_common import csrf_hidden_input from ..svg_icons import ICON_SVG_LOG_OUT, ICON_SVG_USER diff --git a/src/apps/lichess_bridge/components/no_linked_account.py b/src/apps/lichess_bridge/components/no_linked_account.py index 8124987..59ccc11 100644 --- a/src/apps/lichess_bridge/components/no_linked_account.py +++ b/src/apps/lichess_bridge/components/no_linked_account.py @@ -7,7 +7,7 @@ from apps.chess.models import GameFactions from apps.lichess_bridge.components.svg_icons import ICON_SVG_LOG_IN -from apps.webui.components.atoms.button import zc_button +from apps.webui.components.atoms.buttons import zc_button from apps.webui.components.chess_units import unit_display_container from apps.webui.components.forms_common import csrf_hidden_input diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 7042674..94d1eed 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -19,7 +19,7 @@ chess_pieces, ) from apps.chess.components.misc_ui import speech_bubble_container -from apps.webui.components.atoms.button import zc_button, zc_header_icon_button +from apps.webui.components.atoms.buttons import zc_button, zc_header_icon_button from apps.webui.components.layout import page from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button diff --git a/src/apps/webui/components/atoms/button.py b/src/apps/webui/components/atoms/buttons.py similarity index 83% rename from src/apps/webui/components/atoms/button.py rename to src/apps/webui/components/atoms/buttons.py index 6530c33..7738cd8 100644 --- a/src/apps/webui/components/atoms/button.py +++ b/src/apps/webui/components/atoms/buttons.py @@ -6,7 +6,7 @@ from dominate.tags import a, button, span if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from dominate.tags import dom_tag @@ -33,18 +33,18 @@ def zc_button( *, button_type: ButtonType, svg_icon: str | None = None, - href: str | None = None, # if href is not None, the button will be an HTML anchor + href: str | None = None, # if href is not None, the button will be a id_: str | None = None, title: str | None = None, html_type: str | None = None, - additional_classes: Sequence[str] | None = None, - additional_attributes: dict | None = None, - htmx_attributes: dict | None = None, - is_a_help_for_actual_button: bool = False, + extra_classes: Sequence[str] | None = None, + extra_attrs: Mapping[str, str | bool] | None = None, + htmx_attrs: Mapping[str, str | bool] | None = None, + is_a_help_for_actual_button: bool = False, # if True, the button will be a ) -> dom_tag: """A 'zakuchess' (`zc_*`) button.""" - if is_a_help_for_actual_button and htmx_attributes is not None: + if is_a_help_for_actual_button and htmx_attrs is not None: raise ValueError( "Elements that are not actual buttons but " "an help for them should not have htmx attributes" @@ -55,12 +55,12 @@ def zc_button( children.extend((" ", svg_icon)) classes: list[str] = [_BUTTON_TYPE_TO_CLASS_MAPPING[button_type]] - if additional_classes: - classes.extend(additional_classes) + if extra_classes: + classes.extend(extra_classes) attributes: dict = { - **(additional_attributes or {}), - **(htmx_attributes or {}), + **(extra_attrs or {}), + **(htmx_attrs or {}), } if id_: attributes["id"] = id_ diff --git a/src/apps/webui/components/misc_ui/user_prefs_modal.py b/src/apps/webui/components/misc_ui/user_prefs_modal.py index 9e60226..e9eb3ee 100644 --- a/src/apps/webui/components/misc_ui/user_prefs_modal.py +++ b/src/apps/webui/components/misc_ui/user_prefs_modal.py @@ -9,7 +9,7 @@ from apps.chess.components.svg_icons import ICON_SVG_CONFIRM from apps.chess.models import UserPrefsBoardTextureChoices, UserPrefsGameSpeedChoices -from ..atoms.button import zc_button, zc_header_icon_button +from ..atoms.buttons import zc_button, zc_header_icon_button from .svg_icons import ICON_SVG_COG if TYPE_CHECKING: diff --git a/src/apps/webui/components/molecules/__init__.py b/src/apps/webui/components/molecules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/webui/components/molecules/chess_arena_companion_bars.py b/src/apps/webui/components/molecules/chess_arena_companion_bars.py new file mode 100644 index 0000000..c600eff --- /dev/null +++ b/src/apps/webui/components/molecules/chess_arena_companion_bars.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +from dominate.tags import div + +from apps.chess.components.chess_board import INFO_BARS_COMMON_CLASSES +from apps.chess.components.svg_icons import ICON_SVG_CANCEL, ICON_SVG_CONFIRM +from apps.webui.components.atoms.buttons import zc_button + +if TYPE_CHECKING: + from collections.abc import Mapping + + from dominate.dom_tag import dom_tag + +CompanionBarPosition = Literal["top", "bottom"] + + +def companion_bar( + inner_content: dom_tag, + *, + position: CompanionBarPosition, + extra_attrs: Mapping[str, str | bool] | None = None, + htmx_attrs: Mapping[str, str | bool] | None = None, + id_: str | None = None, +) -> dom_tag: + attributes = { + **(extra_attrs or {}), + **(htmx_attrs or {}), + } + if id_: + attributes["id"] = id_ + + classes = ( + f"min-h-[4rem] flex items-center justify-center {INFO_BARS_COMMON_CLASSES}" + ) + match position: + case "top": + classes += " border-t-0 xl:border-2 xl:rounded-t-md" + case "bottom": + classes += " border-t-0 rounded-b-md" + + return div( + inner_content, + cls=classes, + **attributes, + ) + + +def confirmation_dialog_bar( + *, + question: dom_tag, + htmx_attrs_confirm: Mapping[str, str | bool], + htmx_attrs_cancel: Mapping[str, str | bool], + id_: str | None = None, +) -> dom_tag: + inner_content = div( + question, + div( + zc_button( + "Confirm", + button_type="confirm", + svg_icon=ICON_SVG_CONFIRM, + htmx_attrs=htmx_attrs_confirm, + ), + zc_button( + "Cancel", + svg_icon=ICON_SVG_CANCEL, + button_type="cancel", + htmx_attrs=htmx_attrs_cancel, + ), + cls="text-center", + ), + ) + + return companion_bar(inner_content=inner_content, position="top", id_=id_) From 0eadccf79f28132f8233c8f2a3cd587e9aee6456 Mon Sep 17 00:00:00 2001 From: Olivier Philippon Date: Sat, 7 Dec 2024 17:48:50 +0000 Subject: [PATCH 42/42] [lichess] We now have to confirm moves before they are actually sent to Lichess The UI for this is pretty basic for now, but as implementing this and the "atoms" & "molecules" design was my goal for today's session... I'll call it a day! :-) --- .../chess/business_logic/_do_chess_move.py | 2 +- ...do_chess_move_with_piece_role_by_square.py | 2 +- src/apps/chess/components/chess_board.py | 64 ++++++++-- src/apps/chess/exceptions.py | 17 +++ src/apps/chess/presenters.py | 22 +++- src/apps/chess/types.py | 16 --- src/apps/daily_challenge/admin.py | 4 +- .../companion_bars/top_companion_bar.py | 13 +- .../components/pages/daily_chess_pages.py | 10 +- src/apps/daily_challenge/presenters.py | 13 ++ src/apps/daily_challenge/urls.py | 18 +-- src/apps/daily_challenge/views.py | 20 ++-- src/apps/daily_challenge/views_decorators.py | 3 +- .../components/companion_bars/__init__.py | 0 .../companion_bars/top_companion_bar.py | 112 ++++++++++++++++++ .../components/pages/lichess_pages.py | 12 +- src/apps/lichess_bridge/models.py | 4 +- src/apps/lichess_bridge/presenters.py | 34 +++++- src/apps/lichess_bridge/tests/test_views.py | 30 ++++- src/apps/lichess_bridge/urls.py | 5 + src/apps/lichess_bridge/views.py | 57 +++++++-- src/apps/lichess_bridge/views_decorators.py | 14 ++- src/apps/webui/components/atoms/buttons.py | 1 + .../molecules/chess_arena_companion_bars.py | 5 +- 24 files changed, 389 insertions(+), 89 deletions(-) create mode 100644 src/apps/chess/exceptions.py create mode 100644 src/apps/lichess_bridge/components/companion_bars/__init__.py create mode 100644 src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py diff --git a/src/apps/chess/business_logic/_do_chess_move.py b/src/apps/chess/business_logic/_do_chess_move.py index 69dfc04..818e2a5 100644 --- a/src/apps/chess/business_logic/_do_chess_move.py +++ b/src/apps/chess/business_logic/_do_chess_move.py @@ -9,8 +9,8 @@ file_and_rank_from_square, square_from_file_and_rank, ) +from ..exceptions import ChessInvalidMoveException from ..types import ( - ChessInvalidMoveException, ChessMoveResult, GameOverDescription, ) diff --git a/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py index 536109d..1afff1b 100644 --- a/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py +++ b/src/apps/chess/business_logic/_do_chess_move_with_piece_role_by_square.py @@ -6,13 +6,13 @@ get_active_player_side_from_chess_board, get_active_player_side_from_fen, ) +from ..exceptions import ChessInvalidStateException if TYPE_CHECKING: import chess from ..types import ( FEN, - ChessInvalidStateException, ChessMoveResult, PieceRole, PieceRoleBySquare, diff --git a/src/apps/chess/components/chess_board.py b/src/apps/chess/components/chess_board.py index 2eee614..92ab8bd 100644 --- a/src/apps/chess/components/chess_board.py +++ b/src/apps/chess/components/chess_board.py @@ -3,7 +3,7 @@ import json from functools import cache from string import Template -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, cast from chess import FILE_NAMES, RANK_NAMES from django.conf import settings @@ -97,8 +97,16 @@ ) +class ChessArenaCompanionBars(TypedDict): + top: NotRequired[dom_tag] + bottom: NotRequired[dom_tag] + + def chess_arena( - *, game_presenter: GamePresenter, status_bars: list[dom_tag], board_id: str + *, + game_presenter: GamePresenter, + companion_bars: ChessArenaCompanionBars | None = None, + board_id: str, ) -> dom_tag: arena_additional_classes = ( "border-3 border-solid md:border-lime-400 xl:border-red-400" @@ -145,7 +153,8 @@ def chess_arena( ), chess_bot_data(board_id), div( - *status_bars, + companion_bars.get("top", "") if companion_bars else "", + companion_bars.get("bottom", "") if companion_bars else "", id=f"chess-status-bars-{board_id}", cls="xl:px-2 xl:w-1/3 xl:bg-slate-950", ), @@ -423,15 +432,31 @@ def chess_available_targets( if game_presenter.selected_piece and not game_presenter.is_game_over: selected_piece_player_side = game_presenter.selected_piece.player_side - for square in game_presenter.selected_piece.available_targets: + if ( + game_presenter.moves_must_be_confirmed + and game_presenter.target_square_to_confirm is not None + ): + # We're waiting for the user confirmation for a specific move: + # --> let's only display the target square as a target! + # TODO: also display a "preview" of the selected piece on the target square? children.append( chess_available_target( game_presenter=game_presenter, piece_player_side=selected_piece_player_side, - square=square, + square=game_presenter.target_square_to_confirm, board_id=board_id, ) ) + else: + for square in game_presenter.selected_piece.available_targets: + children.append( + chess_available_target( + game_presenter=game_presenter, + piece_player_side=selected_piece_player_side, + square=square, + board_id=board_id, + ) + ) return div( *children, @@ -460,8 +485,15 @@ def chess_available_target( else "bg-non-playable-chess-available-target-marker" ) hover_class = "hover:w-1/3 hover:h-1/3" if can_move else "" + target_marker_size = ( + "w-1/5 h-1/5" + if not game_presenter.moves_must_be_confirmed + or game_presenter.target_square_to_confirm is None + or game_presenter.target_square_to_confirm != square + else "w-1/2 h-1/2" + ) target_marker = div( - cls=f"w-1/5 h-1/5 rounded-full transition-size {bg_class} {hover_class}", + cls=f"{target_marker_size} rounded-full transition-size {bg_class} {hover_class}", ) target_marker_container = div( target_marker, @@ -485,12 +517,20 @@ def chess_available_target( additional_attributes["disabled"] = True if can_move: - htmx_attributes = { - "data_hx_post": game_presenter.urls.htmx_game_move_piece_url( - square=square, board_id=board_id - ), - "data_hx_target": f"#chess-pieces-container-{board_id}", - } + if game_presenter.moves_must_be_confirmed: + htmx_attributes = { + "data_hx_get": game_presenter.urls.htmx_game_move_piece_confirmation_dialog_url( + square=square, board_id=board_id + ), + "data_hx_target": f"#chess-pieces-container-{board_id}", + } + else: + htmx_attributes = { + "data_hx_post": game_presenter.urls.htmx_game_move_piece_url( + square=square, board_id=board_id + ), + "data_hx_target": f"#chess-pieces-container-{board_id}", + } else: htmx_attributes = {} diff --git a/src/apps/chess/exceptions.py b/src/apps/chess/exceptions.py new file mode 100644 index 0000000..782c17e --- /dev/null +++ b/src/apps/chess/exceptions.py @@ -0,0 +1,17 @@ +from __future__ import annotations + + +class ChessLogicException(Exception): + pass + + +class ChessInvalidStateException(ChessLogicException): + pass + + +class ChessInvalidActionException(ChessLogicException): + pass + + +class ChessInvalidMoveException(ChessInvalidActionException): + pass diff --git a/src/apps/chess/presenters.py b/src/apps/chess/presenters.py index 475a0b0..8d76e10 100644 --- a/src/apps/chess/presenters.py +++ b/src/apps/chess/presenters.py @@ -16,10 +16,11 @@ player_side_from_piece_symbol, symbol_from_piece_role, team_member_role_from_piece_role, + type_from_piece_role, ) -from .consts import PLAYER_SIDES +from .consts import PIECE_TYPE_TO_NAME, PLAYER_SIDES +from .exceptions import ChessInvalidStateException from .models import UserPrefs -from .types import ChessInvalidStateException if TYPE_CHECKING: from dominate.util import text @@ -29,6 +30,7 @@ FEN, BoardOrientation, GamePhase, + PieceName, PieceRole, PieceRoleBySquare, PieceSymbol, @@ -71,6 +73,7 @@ def __init__( target_to_confirm: Square | None = None, forced_bot_move: tuple[Square, Square] | None = None, force_square_info: bool = False, + target_square_to_confirm: Square | None = None, last_move: tuple[Square, Square] | None = None, captured_piece_role: PieceRole | None = None, is_preview: bool = False, @@ -86,6 +89,7 @@ def __init__( self.refresh_last_move = refresh_last_move self.is_htmx_request = is_htmx_request self.force_square_info = force_square_info + self.target_square_to_confirm = target_square_to_confirm self.last_move = last_move self.captured_piece_role = captured_piece_role self.is_preview = is_preview @@ -116,6 +120,10 @@ def board_orientation(self) -> BoardOrientation: ... @abstractmethod def urls(self) -> GamePresenterUrls: ... + @property + @abstractmethod + def moves_must_be_confirmed(self) -> bool: ... + @property @abstractmethod def is_my_turn(self) -> bool: ... @@ -256,6 +264,12 @@ def htmx_game_no_selection_url(self, *, board_id: str) -> str: def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: pass + @abstractmethod + def htmx_game_move_piece_confirmation_dialog_url( + self, *, square: Square, board_id: str + ) -> str: + pass + @abstractmethod def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: pass @@ -369,6 +383,10 @@ def is_pinned(self) -> bool: self._chess_lib_square, ) + @cached_property + def piece_name(self) -> PieceName: + return PIECE_TYPE_TO_NAME[type_from_piece_role(self.piece_role)] + def __str__(self) -> str: return f"{self.piece_role} at {self.square}" diff --git a/src/apps/chess/types.py b/src/apps/chess/types.py index 131e320..fabd58d 100644 --- a/src/apps/chess/types.py +++ b/src/apps/chess/types.py @@ -142,19 +142,3 @@ class ChessMoveResult(TypedDict): GameTeamsDict: TypeAlias = "dict[PlayerSide, list[TeamMember]]" - - -class ChessLogicException(Exception): - pass - - -class ChessInvalidStateException(ChessLogicException): - pass - - -class ChessInvalidActionException(ChessLogicException): - pass - - -class ChessInvalidMoveException(ChessInvalidActionException): - pass diff --git a/src/apps/daily_challenge/admin.py b/src/apps/daily_challenge/admin.py index 40f0a8e..5dfb797 100644 --- a/src/apps/daily_challenge/admin.py +++ b/src/apps/daily_challenge/admin.py @@ -302,9 +302,7 @@ def preview_daily_challenge_view(self, request: HttpRequest) -> HttpResponse: else "" ), # Last but certainly not least, display the chess board: - chess_arena( - game_presenter=game_presenter, status_bars=[], board_id=board_id - ), + chess_arena(game_presenter=game_presenter, board_id=board_id), request=request, ) ) diff --git a/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py b/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py index dbeefb6..8528270 100644 --- a/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py +++ b/src/apps/daily_challenge/components/companion_bars/top_companion_bar.py @@ -202,9 +202,9 @@ def _undo_button( htmx_attrs = ( { - "data_hx_post": "".join( + "data_hx_get": "".join( ( - reverse("daily_challenge:htmx_undo_last_move_ask_confirmation"), + reverse("daily_challenge:htmx_undo_last_move_confirmation_dialog"), "?", urlencode({"board_id": board_id}), ) @@ -238,10 +238,10 @@ def _retry_button( htmx_attrs = ( { - "data_hx_post": "".join( + "data_hx_get": "".join( ( reverse( - "daily_challenge:htmx_restart_daily_challenge_ask_confirmation" + "daily_challenge:htmx_restart_daily_challenge_confirmation_dialog" ), "?", urlencode({"board_id": board_id}), @@ -275,8 +275,9 @@ def _see_solution_button( target_route = ( "daily_challenge:htmx_see_daily_challenge_solution_do" if see_it_again - else "daily_challenge:htmx_see_daily_challenge_solution_ask_confirmation" + else "daily_challenge:htmx_see_daily_challenge_solution_confirmation_dialog" ) + target_route_http_method = "post" if see_it_again else "get" target_selector = ( f"#chess-board-pieces-{board_id}" if see_it_again @@ -289,7 +290,7 @@ def _see_solution_button( ) htmx_attrs = { - "data_hx_post": "".join( + f"data_hx_{target_route_http_method}": "".join( ( reverse(target_route), "?", diff --git a/src/apps/daily_challenge/components/pages/daily_chess_pages.py b/src/apps/daily_challenge/components/pages/daily_chess_pages.py index 585ef7b..0813a47 100644 --- a/src/apps/daily_challenge/components/pages/daily_chess_pages.py +++ b/src/apps/daily_challenge/components/pages/daily_chess_pages.py @@ -54,13 +54,15 @@ def daily_challenge_page( chess_arena( game_presenter=game_presenter, board_id=board_id, - status_bars=[ - daily_challenge_bar(game_presenter=game_presenter, board_id=board_id), - status_bar( + companion_bars={ + "top": daily_challenge_bar( + game_presenter=game_presenter, board_id=board_id + ), + "bottom": status_bar( game_presenter=game_presenter, board_id=board_id, ), - ], + }, ), _open_help_modal() if game_presenter.is_very_first_game else div(""), request=request, diff --git a/src/apps/daily_challenge/presenters.py b/src/apps/daily_challenge/presenters.py index 7f3ba0e..50e13ff 100644 --- a/src/apps/daily_challenge/presenters.py +++ b/src/apps/daily_challenge/presenters.py @@ -84,6 +84,12 @@ def board_orientation(self) -> BoardOrientation: def urls(self) -> DailyChallengeGamePresenterUrls: return DailyChallengeGamePresenterUrls(game_presenter=self) + @property + def moves_must_be_confirmed(self) -> bool: + # Daily challenges are meant to be played quickly, with infinite number of + # attempts, so we shouldn't need to confirm the moves. + return False + @cached_property def is_my_turn(self) -> bool: return not self.is_bot_turn @@ -216,6 +222,13 @@ def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: ) ) + def htmx_game_move_piece_confirmation_dialog_url( + self, *, square: Square, board_id: str + ) -> str: + raise NotImplementedError( + "Daily challenges don't have a move confirmation dialog" + ) + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: assert self._game_presenter.selected_piece is not None # type checker: happy return "".join( diff --git a/src/apps/daily_challenge/urls.py b/src/apps/daily_challenge/urls.py index 0778291..15119fa 100644 --- a/src/apps/daily_challenge/urls.py +++ b/src/apps/daily_challenge/urls.py @@ -39,9 +39,9 @@ ), # Restart views path( - "htmx/daily-challenge/restart/ask-confirmation/", - views.htmx_restart_daily_challenge_ask_confirmation, - name="htmx_restart_daily_challenge_ask_confirmation", + "htmx/daily-challenge/restart/confirmation-dialog/", + views.htmx_restart_daily_challenge_confirmation_dialog, + name="htmx_restart_daily_challenge_confirmation_dialog", ), path( "htmx/daily-challenge/restart/do/", @@ -50,9 +50,9 @@ ), # Undo views path( - "htmx/daily-challenge/undo/ask-confirmation/", - views.htmx_undo_last_move_ask_confirmation, - name="htmx_undo_last_move_ask_confirmation", + "htmx/daily-challenge/undo/confirmation-dialog/", + views.htmx_undo_last_move_confirmation_dialog, + name="htmx_undo_last_move_confirmation_dialog", ), path( "htmx/daily-challenge/undo/do/", @@ -61,9 +61,9 @@ ), # "See the solution" views path( - "htmx/daily-challenge/see-solution/ask-confirmation/", - views.htmx_see_daily_challenge_solution_ask_confirmation, - name="htmx_see_daily_challenge_solution_ask_confirmation", + "htmx/daily-challenge/see-solution/confirmation-dialog/", + views.htmx_see_daily_challenge_solution_confirmation_dialog, + name="htmx_see_daily_challenge_solution_confirmation_dialog", ), path( "htmx/daily-challenge/see-solution/do/", diff --git a/src/apps/daily_challenge/views.py b/src/apps/daily_challenge/views.py index 6feab8c..5ac9c60 100644 --- a/src/apps/daily_challenge/views.py +++ b/src/apps/daily_challenge/views.py @@ -10,7 +10,7 @@ from django.views.decorators.http import require_POST, require_safe from apps.chess.chess_helpers import get_active_player_side_from_fen, uci_move_squares -from apps.chess.types import ChessInvalidActionException, ChessInvalidMoveException +from apps.chess.exceptions import ChessInvalidActionException, ChessInvalidMoveException from apps.utils.view_decorators import user_is_staff from apps.utils.views_helpers import htmx_aware_redirect @@ -250,13 +250,13 @@ def htmx_daily_challenge_help_modal( return HttpResponse(str(modal_content)) -@require_POST +@require_safe @with_game_context @redirect_if_game_not_started -def htmx_restart_daily_challenge_ask_confirmation( +def htmx_restart_daily_challenge_confirmation_dialog( request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + from .components.companion_bars.top_companion_bar import ( retry_confirmation_dialog_bar, ) @@ -300,13 +300,13 @@ def htmx_restart_daily_challenge_do( ) -@require_POST +@require_safe @with_game_context @redirect_if_game_not_started -def htmx_undo_last_move_ask_confirmation( +def htmx_undo_last_move_confirmation_dialog( request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + from .components.companion_bars.top_companion_bar import ( undo_confirmation_dialog_bar, ) @@ -342,13 +342,13 @@ def htmx_undo_last_move_do(request: HttpRequest, *, ctx: GameContext) -> HttpRes ) -@require_POST +@require_safe @with_game_context @redirect_if_game_not_started -def htmx_see_daily_challenge_solution_ask_confirmation( +def htmx_see_daily_challenge_solution_confirmation_dialog( request: HttpRequest, *, ctx: GameContext ) -> HttpResponse: - from apps.daily_challenge.components.companion_bars.top_companion_bar import ( + from .components.companion_bars.top_companion_bar import ( see_solution_confirmation_dialog_bar, ) diff --git a/src/apps/daily_challenge/views_decorators.py b/src/apps/daily_challenge/views_decorators.py index 76069c8..14bc64d 100644 --- a/src/apps/daily_challenge/views_decorators.py +++ b/src/apps/daily_challenge/views_decorators.py @@ -5,8 +5,7 @@ from django.core.exceptions import BadRequest -from apps.chess.types import ChessLogicException - +from ..chess.exceptions import ChessLogicException from ..utils.views_helpers import htmx_aware_redirect from .cookie_helpers import clear_daily_challenge_game_state_in_session from .view_helpers import GameContext diff --git a/src/apps/lichess_bridge/components/companion_bars/__init__.py b/src/apps/lichess_bridge/components/companion_bars/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py b/src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py new file mode 100644 index 0000000..66a7c04 --- /dev/null +++ b/src/apps/lichess_bridge/components/companion_bars/top_companion_bar.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlencode + +from django.urls import reverse +from dominate.tags import b, div, p + +from apps.webui.components.molecules.chess_arena_companion_bars import ( + companion_bar, + confirmation_dialog_bar, +) + +if TYPE_CHECKING: + from dominate.tags import dom_tag + + from ...presenters import LichessCorrespondenceGamePresenter + + +def lichess_bridge_bar( + *, + game_presenter: LichessCorrespondenceGamePresenter, + board_id: str, + htmx_attrs: dict[str, str] | None = None, +) -> dom_tag: + if game_presenter.target_square_to_confirm is not None: + return move_piece_confirmation_dialog_bar( + game_presenter=game_presenter, htmx_attrs=htmx_attrs, board_id=board_id + ) + + inner_content = div( + p( + "Game against ", + b(game_presenter.opponent_username), + " on Lichess. ", + f"Turn #{game_presenter.chess_board.fullmove_number}", + cls="text-center", + ), + p( + "Your turn! 🙂" + if game_presenter.is_my_turn + else "Waiting for them to move. ⏳", + cls="text-center", + ), + ) + + return companion_bar( + inner_content, + id_=f"chess-board-lichess-bridge-bar-{board_id}", + position="top", + htmx_attrs=htmx_attrs, + ) + + +def move_piece_confirmation_dialog_bar( + *, + game_presenter: LichessCorrespondenceGamePresenter, + htmx_attrs: dict[str, str] | None = None, + board_id: str, +) -> dom_tag: + assert game_presenter.selected_piece is not None + assert game_presenter.target_square_to_confirm is not None + + htmx_attrs_confirm = { + "data_hx_post": "".join( + ( + reverse( + "lichess_bridge:htmx_game_move_piece", + kwargs={ + "game_id": game_presenter.game_id, + "from_": game_presenter.selected_piece.square, + "to": game_presenter.target_square_to_confirm, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ), + "data_hx_target": f"#chess-board-pieces-{board_id}", + "data_hx_swap": "outerHTML", + } + htmx_attrs_cancel = { + "data_hx_get": "".join( + ( + reverse( + "lichess_bridge:htmx_game_no_selection", + kwargs={"game_id": game_presenter.game_id}, + ), + "?", + urlencode({"board_id": board_id}), + ) + ), + "data_hx_target": f"#chess-board-pieces-{board_id}", + "data_hx_swap": "outerHTML", + } + + return confirmation_dialog_bar( + question=div( + "Move this ", + b(game_presenter.selected_piece.piece_name), + " from ", + b(game_presenter.selected_piece.square), + " to ", + b(game_presenter.target_square_to_confirm), + "?", + cls="text-center", + ), + htmx_attrs_confirm=htmx_attrs_confirm, + htmx_attrs_cancel=htmx_attrs_cancel, + id_=f"chess-board-lichess-bridge-bar-{board_id}", + htmx_attrs=htmx_attrs, + ) diff --git a/src/apps/lichess_bridge/components/pages/lichess_pages.py b/src/apps/lichess_bridge/components/pages/lichess_pages.py index 94d1eed..271ab4c 100644 --- a/src/apps/lichess_bridge/components/pages/lichess_pages.py +++ b/src/apps/lichess_bridge/components/pages/lichess_pages.py @@ -23,6 +23,7 @@ from apps.webui.components.layout import page from apps.webui.components.misc_ui.user_prefs_modal import user_prefs_button +from ..companion_bars.top_companion_bar import lichess_bridge_bar from ..game_creation import game_creation_form from ..no_linked_account import no_linked_account_content from ..ongoing_games import lichess_ongoing_games @@ -134,7 +135,11 @@ def lichess_correspondence_game_page( return page( chess_arena( game_presenter=game_presenter, - status_bars=[], + companion_bars={ + "top": lichess_bridge_bar( + game_presenter=game_presenter, board_id="main" + ), + }, board_id="main", ), _lichess_account_footer(me), @@ -171,6 +176,11 @@ def lichess_game_moving_parts_fragment( if game_presenter.refresh_last_move else div("") ), + lichess_bridge_bar( + game_presenter=game_presenter, + board_id=board_id, + htmx_attrs={"data_hx_swap_oob": "outerHTML"}, + ), div( speech_bubble_container( game_presenter=game_presenter, diff --git a/src/apps/lichess_bridge/models.py b/src/apps/lichess_bridge/models.py index 96b72ef..7c14a9a 100644 --- a/src/apps/lichess_bridge/models.py +++ b/src/apps/lichess_bridge/models.py @@ -369,8 +369,10 @@ def _players_sides(self) -> LichessGameMetadataPlayerSides: @functools.cached_property def _rebuilt_game(self) -> RebuildGameFromMovesResult: + moves_str = self.raw_data.state.moves.strip() + return rebuild_game_from_moves( - uci_moves=self.raw_data.state.moves.strip().split(" "), + uci_moves=moves_str.split(" ") if moves_str else [], factions=self.game_factions, ) diff --git a/src/apps/lichess_bridge/presenters.py b/src/apps/lichess_bridge/presenters.py index 8dea4a3..674cedc 100644 --- a/src/apps/lichess_bridge/presenters.py +++ b/src/apps/lichess_bridge/presenters.py @@ -30,6 +30,7 @@ def __init__( game_data: LichessGameFullFromStreamWithMetadata, refresh_last_move: bool, is_htmx_request: bool, + target_square_to_confirm: Square | None = None, selected_piece_square: Square | None = None, user_prefs: UserPrefs | None = None, ): @@ -48,6 +49,7 @@ def __init__( piece_role_by_square=game_data.piece_role_by_square, teams=game_data.teams, refresh_last_move=refresh_last_move, + target_square_to_confirm=target_square_to_confirm, is_htmx_request=is_htmx_request, selected_piece_square=selected_piece_square, last_move=last_move, @@ -62,6 +64,11 @@ def board_orientation(self) -> BoardOrientation: def urls(self) -> GamePresenterUrls: return LichessCorrespondenceGamePresenterUrls(game_presenter=self) + @property + def moves_must_be_confirmed(self) -> bool: + # TODO: make this dynamic, via a user setting? + return True + @cached_property def is_my_turn(self) -> bool: return self._game_data.players_from_my_perspective.active_player == "me" @@ -109,6 +116,10 @@ def player_side_to_highlight_all_pieces_for(self) -> PlayerSide | None: def speech_bubble(self) -> SpeechBubbleData | None: return None + @cached_property + def opponent_username(self) -> str: + return self._game_data.players_from_my_perspective.them.username + class LichessCorrespondenceGamePresenterUrls(GamePresenterUrls): def htmx_game_no_selection_url(self, *, board_id: str) -> str: @@ -140,6 +151,25 @@ def htmx_game_select_piece_url(self, *, square: Square, board_id: str) -> str: ) ) + def htmx_game_move_piece_confirmation_dialog_url( + self, *, square: Square, board_id: str + ) -> str: + assert self._game_presenter.selected_piece is not None # type checker: happy + return "".join( + ( + reverse( + "lichess_bridge:htmx_game_move_piece_confirmation_dialog", + kwargs={ + "game_id": self._game_presenter.game_id, + "from_": self._game_presenter.selected_piece.square, + "to": square, + }, + ), + "?", + urlencode({"board_id": board_id}), + ) + ) + def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: assert self._game_presenter.selected_piece is not None # type checker: happy return "".join( @@ -158,7 +188,7 @@ def htmx_game_move_piece_url(self, *, square: Square, board_id: str) -> str: ) def htmx_game_play_bot_move_url(self, *, board_id: str) -> str: - return "#" # TODO + raise NotImplementedError("No bots on Lichess games") def htmx_game_play_solution_move_url(self, *, board_id: str) -> str: - return "#" # TODO + raise NotImplementedError("No game solution on Lichess games") diff --git a/src/apps/lichess_bridge/tests/test_views.py b/src/apps/lichess_bridge/tests/test_views.py index 4b56243..58b8d6b 100644 --- a/src/apps/lichess_bridge/tests/test_views.py +++ b/src/apps/lichess_bridge/tests/test_views.py @@ -110,7 +110,35 @@ async def test_lichess_create_game_with_access_token_smoke_test( access_token = "lio_123456789" async_client.cookies["lichess.access_token"] = access_token - response = await async_client.get("/lichess/games/new/") + class HttpClientMock(HttpClientMockBase): + class HttpClientResponseMock(HttpClientResponseMockBase): + @property + def content(self) -> str: + # The client's response's `content` is a property + result: dict[str, Any] = {} + match self.path: + case "/api/account": + result = { + "id": "chesschampion", + "url": "https://lichess.org/@/chesschampion", + "username": "ChessChampion", + } + case _: + raise ValueError(f"Unexpected path: {self.path}") + return json.dumps(result) + + async def get(self, path, **kwargs): + # The client's `get` method is async + assert path.startswith("/api/") + return self.HttpClientResponseMock(path) + + with mock.patch( + "apps.lichess_bridge.lichess_api._create_lichess_api_client", + ) as create_lichess_api_client_mock: + create_lichess_api_client_mock.return_value.__aenter__.return_value = ( + HttpClientMock(access_token) + ) + response = await async_client.get("/lichess/games/new/") assert response.status_code == HTTPStatus.OK diff --git a/src/apps/lichess_bridge/urls.py b/src/apps/lichess_bridge/urls.py index e4735eb..6da2736 100644 --- a/src/apps/lichess_bridge/urls.py +++ b/src/apps/lichess_bridge/urls.py @@ -28,6 +28,11 @@ views.htmx_game_select_piece, name="htmx_game_select_piece", ), + path( + "htmx/games/correspondence//pieces//move//confirmation-dialog/", + views.htmx_game_move_piece_confirmation_dialog, + name="htmx_game_move_piece_confirmation_dialog", + ), path( "htmx/games/correspondence//pieces//move//", views.htmx_game_move_piece, diff --git a/src/apps/lichess_bridge/views.py b/src/apps/lichess_bridge/views.py index 1d595f9..3584189 100644 --- a/src/apps/lichess_bridge/views.py +++ b/src/apps/lichess_bridge/views.py @@ -11,6 +11,11 @@ require_safe, ) +from apps.chess.exceptions import ( + ChessInvalidActionException, + ChessInvalidMoveException, +) + from . import cookie_helpers, lichess_api from .authentication import ( LichessTokenRetrievalProcessContext, @@ -35,11 +40,7 @@ from django.http import HttpRequest from apps.chess.models import UserPrefs - from apps.chess.types import ( - ChessInvalidActionException, - ChessInvalidMoveException, - Square, - ) + from apps.chess.types import Square from .models import ( LichessAccessToken, @@ -69,7 +70,6 @@ async def lichess_home_page( @require_safe -@with_lichess_access_token @redirect_if_no_lichess_access_token async def lichess_my_games_list_page( request: HttpRequest, lichess_access_token: LichessAccessToken @@ -83,7 +83,6 @@ async def lichess_my_games_list_page( @require_http_methods(["GET", "POST"]) -@with_lichess_access_token @redirect_if_no_lichess_access_token async def lichess_game_create_form_page( request: HttpRequest, *, lichess_access_token: LichessAccessToken @@ -116,7 +115,6 @@ async def lichess_game_create_form_page( @require_safe -@with_lichess_access_token @with_user_prefs @redirect_if_no_lichess_access_token async def lichess_correspondence_game_page( @@ -146,7 +144,6 @@ async def lichess_correspondence_game_page( @require_safe -@with_lichess_access_token @with_user_prefs @redirect_if_no_lichess_access_token async def htmx_lichess_correspondence_game_no_selection( @@ -170,7 +167,6 @@ async def htmx_lichess_correspondence_game_no_selection( @require_safe -@with_lichess_access_token @with_user_prefs @redirect_if_no_lichess_access_token @handle_chess_logic_exceptions @@ -196,8 +192,46 @@ async def htmx_game_select_piece( ) +@require_safe +@with_user_prefs +@redirect_if_no_lichess_access_token +@handle_chess_logic_exceptions +async def htmx_game_move_piece_confirmation_dialog( + request: HttpRequest, + *, + lichess_access_token: LichessAccessToken, + game_id: LichessGameId, + from_: Square, + to: Square, + user_prefs: UserPrefs | None, +) -> HttpResponse: + if from_ == to: + raise ChessInvalidMoveException("Not a move") + + me, game_data = await _get_game_context_from_lichess(lichess_access_token, game_id) + + if not game_data.raw_data.is_ongoing_game: + raise ChessInvalidActionException("Game is over, cannot move pieces") + + is_my_turn = game_data.players_from_my_perspective.active_player == "me" + if not is_my_turn: + raise ChessInvalidMoveException("Not my turn") + + game_presenter = LichessCorrespondenceGamePresenter( + game_data=game_data, + selected_piece_square=from_, + target_square_to_confirm=to, + is_htmx_request=True, + refresh_last_move=False, + user_prefs=user_prefs, + ) + + return _lichess_game_moving_parts_fragment_response( + game_presenter=game_presenter, request=request, board_id="main" + ) + + @require_POST -@with_lichess_access_token @with_user_prefs @redirect_if_no_lichess_access_token @handle_chess_logic_exceptions @@ -258,7 +292,6 @@ async def htmx_game_move_piece( @require_safe -@with_lichess_access_token @redirect_if_no_lichess_access_token async def htmx_user_account_modal( request: HttpRequest, diff --git a/src/apps/lichess_bridge/views_decorators.py b/src/apps/lichess_bridge/views_decorators.py index 717b24a..398185c 100644 --- a/src/apps/lichess_bridge/views_decorators.py +++ b/src/apps/lichess_bridge/views_decorators.py @@ -7,15 +7,13 @@ from django.core.exceptions import BadRequest from django.shortcuts import redirect -from ..chess.types import ChessLogicException +from ..chess.exceptions import ChessLogicException from ..webui.cookie_helpers import get_user_prefs_from_request from . import cookie_helpers if TYPE_CHECKING: from django.http import HttpRequest - from .models import LichessAccessToken - def with_lichess_access_token(func): if iscoroutinefunction(func): @@ -68,9 +66,12 @@ def redirect_if_no_lichess_access_token(func): async def wrapper( request: HttpRequest, *args, - lichess_access_token: LichessAccessToken | None, **kwargs, ): + assert "lichess_access_token" not in kwargs + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) if not lichess_access_token: return redirect("lichess_bridge:homepage") return await func( @@ -83,9 +84,12 @@ async def wrapper( def wrapper( request: HttpRequest, *args, - lichess_access_token: LichessAccessToken | None, **kwargs, ): + assert "lichess_access_token" not in kwargs + lichess_access_token = ( + cookie_helpers.get_lichess_api_access_token_from_request(request) + ) if not lichess_access_token: return redirect("lichess_bridge:homepage") return func( diff --git a/src/apps/webui/components/atoms/buttons.py b/src/apps/webui/components/atoms/buttons.py index 7738cd8..614dad1 100644 --- a/src/apps/webui/components/atoms/buttons.py +++ b/src/apps/webui/components/atoms/buttons.py @@ -95,6 +95,7 @@ def zc_button( def zc_header_icon_button( *, icon: str, title: str, id_: str, htmx_attributes: dict[str, str] ) -> dom_tag: + """A 'zakuchess' (`zc_*`) header button, visually displayed as an icon.""" return button( icon, cls="block px-1 py-1 text-sm text-slate-50 hover:text-slate-400", diff --git a/src/apps/webui/components/molecules/chess_arena_companion_bars.py b/src/apps/webui/components/molecules/chess_arena_companion_bars.py index c600eff..22bffe2 100644 --- a/src/apps/webui/components/molecules/chess_arena_companion_bars.py +++ b/src/apps/webui/components/molecules/chess_arena_companion_bars.py @@ -52,6 +52,7 @@ def confirmation_dialog_bar( question: dom_tag, htmx_attrs_confirm: Mapping[str, str | bool], htmx_attrs_cancel: Mapping[str, str | bool], + htmx_attrs: Mapping[str, str | bool] | None = None, id_: str | None = None, ) -> dom_tag: inner_content = div( @@ -73,4 +74,6 @@ def confirmation_dialog_bar( ), ) - return companion_bar(inner_content=inner_content, position="top", id_=id_) + return companion_bar( + inner_content=inner_content, position="top", id_=id_, htmx_attrs=htmx_attrs + )