From 195a1892d02866c02dc82b6d6d0fc50a8ee68d33 Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Sun, 29 Jun 2025 11:56:01 -0300 Subject: [PATCH] misc: Improve API docs and annotations for rom endpoints * Add type annotations for FastAPI header, query, and path parameters. * Add type annotations for request body content. * Update docstrings to clarify endpoint functionality, and remove unnecessary details. --- backend/endpoints/rom.py | 447 +++++++++++++++++++++++---------------- poetry.lock | 16 +- pyproject.toml | 2 +- 3 files changed, 273 insertions(+), 192 deletions(-) diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index fc45f721e..0035539b9 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from io import BytesIO from shutil import rmtree from stat import S_IFREG -from typing import Any, TypeVar +from typing import Annotated, Any from urllib.parse import quote from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo @@ -27,7 +27,19 @@ from endpoints.responses.rom import ( ) from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from exceptions.fs_exceptions import RomAlreadyExistsException -from fastapi import HTTPException, Query, Request, UploadFile, status +from fastapi import ( + Body, + File, + Header, + HTTPException, +) +from fastapi import Path as PathVar +from fastapi import ( + Query, + Request, + UploadFile, + status, +) from fastapi.responses import Response from fastapi_pagination.ext.sqlalchemy import paginate from fastapi_pagination.limit_offset import LimitOffsetPage, LimitOffsetParams @@ -47,6 +59,7 @@ from logger.formatter import highlight as hl from logger.logger import log from models.rom import RomFile from PIL import Image +from pydantic import BaseModel from starlette.requests import ClientDisconnect from starlette.responses import FileResponse from streaming_form_data import StreamingFormDataParser @@ -56,38 +69,47 @@ from utils.hashing import crc32_to_hex from utils.nginx import FileRedirectResponse, ZipContentLine, ZipResponse from utils.router import APIRouter -T = TypeVar("T") - router = APIRouter( prefix="/roms", tags=["roms"], ) -@protected_route(router.post, "", [Scope.ROMS_WRITE]) -async def add_rom(request: Request): - """Upload single rom endpoint +@protected_route( + router.post, + "", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, + responses={status.HTTP_400_BAD_REQUEST: {}}, +) +async def add_rom( + request: Request, + platform_id: Annotated[ + int, + Header(description="Platform internal id.", ge=1, alias="x-upload-platform"), + ], + filename: Annotated[ + str, + Header( + description="The name of the file being uploaded.", + alias="x-upload-filename", + ), + ], +) -> Response: + """Upload a single rom.""" - Args: - request (Request): Fastapi Request object - - Raises: - HTTPException: No files were uploaded - """ - platform_id = request.headers.get("x-upload-platform") - filename = request.headers.get("x-upload-filename") if not platform_id or not filename: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No platform ID or filename provided", - ) from None + ) - db_platform = db_platform_handler.get_platform(int(platform_id)) + db_platform = db_platform_handler.get_platform(platform_id) if not db_platform: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Platform not found", - ) from None + ) platform_fs_slug = db_platform.fs_slug roms_path = fs_rom_handler.build_upload_fs_path(platform_fs_slug) @@ -106,7 +128,7 @@ async def add_rom(request: Request): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"File {filename} already exists", - ) from None + ) # Create the directory if it doesn't exist if not await file_location.parent.exists(): @@ -130,7 +152,7 @@ async def add_rom(request: Request): detail="There was an error uploading the file(s)", ) from exc - return Response(status_code=status.HTTP_201_CREATED) + return Response() class CustomLimitOffsetParams(LimitOffsetParams): @@ -139,7 +161,7 @@ class CustomLimitOffsetParams(LimitOffsetParams): offset: int = Query(0, ge=0, description="Page offset") -class CustomLimitOffsetPage(LimitOffsetPage[T]): +class CustomLimitOffsetPage[T: BaseModel](LimitOffsetPage[T]): char_index: dict[str, int] __params_type__ = CustomLimitOffsetParams @@ -147,58 +169,100 @@ class CustomLimitOffsetPage(LimitOffsetPage[T]): @protected_route(router.get, "", [Scope.ROMS_READ]) def get_roms( request: Request, - platform_id: int | None = None, - collection_id: int | None = None, - virtual_collection_id: str | None = None, - search_term: str | None = None, - order_by: str = "name", - order_dir: str = "asc", - matched: bool | None = None, - favourite: bool | None = None, - duplicate: bool | None = None, - playable: bool | None = None, - missing: bool | None = None, - has_ra: bool | None = None, - verified: bool | None = None, - group_by_meta_id: bool = False, - selected_genre: str | None = None, - selected_franchise: str | None = None, - selected_collection: str | None = None, - selected_company: str | None = None, - selected_age_rating: str | None = None, - selected_status: str | None = None, - selected_region: str | None = None, - selected_language: str | None = None, + search_term: Annotated[ + str | None, + Query(description="Search term to filter roms."), + ] = None, + platform_id: Annotated[ + int | None, + Query(description="Platform internal id.", ge=1), + ] = None, + collection_id: Annotated[ + int | None, + Query(description="Collection internal id.", ge=1), + ] = None, + virtual_collection_id: Annotated[ + str | None, + Query(description="Virtual collection internal id."), + ] = None, + matched: Annotated[ + bool | None, + Query(description="Whether the rom matched a metadata source."), + ] = None, + favourite: Annotated[ + bool | None, + Query(description="Whether the rom is marked as favourite."), + ] = None, + duplicate: Annotated[ + bool | None, + Query(description="Whether the rom is marked as duplicate."), + ] = None, + playable: Annotated[ + bool | None, + Query(description="Whether the rom is playable from the browser."), + ] = None, + missing: Annotated[ + bool | None, + Query(description="Whether the rom is missing from the filesystem."), + ] = None, + has_ra: Annotated[ + bool | None, + Query(description="Whether the rom has RetroAchievements data."), + ] = None, + verified: Annotated[ + bool | None, + Query( + description="Whether the rom is verified by Hasheous from the filesystem." + ), + ] = None, + group_by_meta_id: Annotated[ + bool, + Query( + description="Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox)." + ), + ] = False, + selected_genre: Annotated[ + str | None, + Query(description="Associated genre."), + ] = None, + selected_franchise: Annotated[ + str | None, + Query(description="Associated franchise."), + ] = None, + selected_collection: Annotated[ + str | None, + Query(description="Associated collection."), + ] = None, + selected_company: Annotated[ + str | None, + Query(description="Associated company."), + ] = None, + selected_age_rating: Annotated[ + str | None, + Query(description="Associated age rating."), + ] = None, + selected_status: Annotated[ + str | None, + Query(description="Game status, set by the current user."), + ] = None, + selected_region: Annotated[ + str | None, + Query(description="Associated region tag."), + ] = None, + selected_language: Annotated[ + str | None, + Query(description="Associated language tag."), + ] = None, + order_by: Annotated[ + str, + Query(description="Field to order results by."), + ] = "name", + order_dir: Annotated[ + str, + Query(description="Order direction, either 'asc' or 'desc'."), + ] = "asc", ) -> CustomLimitOffsetPage[SimpleRomSchema]: - """Get roms endpoint - - Args: - request: Fastapi Request object - platform_id (int, optional): Platform internal id. Defaults to None. - collection_id (int, optional): Collection internal id. Defaults to None. - virtual_collection_id (str, optional): Virtual collection internal id. Defaults to None. - search_term (str, optional): Search term to filter roms. Defaults to None. - order_by (str, optional): Field to order by. Defaults to "name". - order_dir (str, optional): Order direction. Defaults to "asc". - matched (bool, optional): Filter for matched or unmatched roms. Defaults to None. - favourite (bool, optional): Filter for favourite or non-favourite roms. Defaults to None. - duplicate (bool, optional): Filter for duplicate or non-duplicate roms. Defaults to None. - playable (bool, optional): Filter for playable or non-playable roms. Defaults to None. - missing (bool, optional): Filter only roms that are missing from the filesystem. Defaults to False. - verified (bool, optional): Filter only roms that are verified by hasheous from the filesystem. Defaults to False. - group_by_meta_id (bool, optional): Group roms by igdb/moby/ssrf/launchbox ID. Defaults to False. - selected_genre (str, optional): Filter by genre. Defaults to None. - selected_franchise (str, optional): Filter by franchise. Defaults to None. - selected_collection (str, optional): Filter by collection. Defaults to None. - selected_company (str, optional): Filter by company. Defaults to None. - selected_age_rating (str, optional): Filter by age rating. Defaults to None. - selected_status (str, optional): Filter by status. Defaults to None. - selected_region (str, optional): Filter by region tag. Defaults to None. - selected_language (str, optional): Filter by language tag. Defaults to None. - - Returns: - list[RomSchema | SimpleRomSchema]: List of ROMs stored in the database - """ + """Retrieve roms.""" # Get the base roms query query = db_rom_handler.get_roms_query( @@ -252,17 +316,13 @@ def get_roms( router.get, "/{id}", [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, ) -def get_rom(request: Request, id: int) -> DetailedRomSchema: - """Get rom endpoint - - Args: - request (Request): Fastapi Request object - id (int): Rom internal id - - Returns: - DetailedRomSchema: Rom stored in the database - """ +def get_rom( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], +) -> DetailedRomSchema: + """Retrieve a rom by ID.""" rom = db_rom_handler.get_rom(id) @@ -276,32 +336,30 @@ def get_rom(request: Request, id: int) -> DetailedRomSchema: router.head, "/{id}/content/{file_name}", [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, ) async def head_rom_content( request: Request, - id: int, - file_name: str, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + file_name: Annotated[str, PathVar(description="File name to download")], + file_ids: Annotated[ + str | None, + Query( + description="Comma-separated list of file ids to download for multi-part roms." + ), + ] = None, ): - """Head rom content endpoint - - Args: - request (Request): Fastapi Request object - id (int): Rom internal id - file_name (str): File name to download - file_ids (list[int]): List of file ids to download for multi-part roms - - Returns: - FileResponse: Returns the response with headers - """ + """Retrieve head information for a rom file download.""" rom = db_rom_handler.get_rom(id) if not rom: raise RomNotFoundInDatabaseException(id) - file_ids = request.query_params.get("file_ids") or "" - file_ids = [int(f) for f in file_ids.split(",") if f] - files = [f for f in rom.files if f.id in file_ids or not file_ids] + files = rom.files + if file_ids: + file_id_values = {int(f.strip()) for f in file_ids.split(",") if f.strip()} + files = [f for f in rom.files if f.id in file_id_values] files.sort(key=lambda x: x.file_name) # Serve the file directly in development mode for emulatorjs @@ -344,26 +402,24 @@ async def head_rom_content( router.get, "/{id}/content/{file_name}", [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, ) async def get_rom_content( request: Request, - id: int, - file_name: str, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + file_name: Annotated[str, PathVar(description="Zip file output name")], + file_ids: Annotated[ + str | None, + Query( + description="Comma-separated list of file ids to download for multi-part roms." + ), + ] = None, ): - """Download rom endpoint (one single file or multiple zipped files for multi-part roms) + """Download a rom. - Args: - request (Request): Fastapi Request object - id (int): Rom internal id - file_name: Zip file output name - - Returns: - Response: Returns a response with headers - - Yields: - FileResponse: Returns one file for single file roms - FileRedirectResponse: Redirects to the file download path - ZipResponse: Returns a response for nginx to serve a Zip file for multi-part roms + This endpoint serves the content of the requested rom, as: + - A single file for single file roms. + - A zipped file for multi-part roms, including a .m3u file if applicable. """ current_username = ( @@ -377,9 +433,10 @@ async def get_rom_content( # https://muos.dev/help/addcontent#what-about-multi-disc-content hidden_folder = str_to_bool(request.query_params.get("hidden_folder", "")) - file_ids = request.query_params.get("file_ids") or "" - file_ids = [int(f) for f in file_ids.split(",") if f] - files = [f for f in rom.files if f.id in file_ids or not file_ids] + files = rom.files + if file_ids: + file_id_values = {int(f.strip()) for f in file_ids.split(",") if f.strip()} + files = [f for f in rom.files if f.id in file_id_values] files.sort(key=lambda x: x.file_name) log.info( @@ -495,28 +552,29 @@ async def get_rom_content( ) -@protected_route(router.put, "/{id}", [Scope.ROMS_WRITE]) +@protected_route( + router.put, + "/{id}", + [Scope.ROMS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) async def update_rom( request: Request, - id: int, - remove_cover: bool = False, - artwork: UploadFile | None = None, - unmatch_metadata: bool = False, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + artwork: Annotated[ + UploadFile | None, + File(description="Custom artwork to set as cover."), + ] = None, + remove_cover: Annotated[ + bool, + Query(description="Whether to remove the cover image for this rom."), + ] = False, + unmatch_metadata: Annotated[ + bool, + Query(description="Whether to remove the metadata matches for this game."), + ] = False, ) -> DetailedRomSchema: - """Update rom endpoint - - Args: - request (Request): Fastapi Request object - id (Rom): Rom internal id - artwork (UploadFile, optional): Custom artwork to set as cover. Defaults to File(None). - unmatch_metadata: Remove the metadata matches for this game. Defaults to False. - - Raises: - HTTPException: Rom not found in database - - Returns: - DetailedRomSchema: Rom stored in the database - """ + """Update a rom.""" data = await request.form() @@ -728,22 +786,30 @@ async def update_rom( return DetailedRomSchema.from_orm_with_request(rom, request) -@protected_route(router.post, "/{id}/manuals", [Scope.ROMS_WRITE]) -async def add_rom_manuals(request: Request, id: int): - """Upload manuals for a rom +@protected_route( + router.post, + "/{id}/manuals", + [Scope.ROMS_WRITE], + status_code=status.HTTP_201_CREATED, + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def add_rom_manuals( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + filename: Annotated[ + str, + Header( + description="The name of the file being uploaded.", + alias="x-upload-filename", + ), + ], +) -> Response: + """Upload manuals for a rom.""" - Args: - request (Request): Fastapi Request object - - Raises: - HTTPException: No files were uploaded - """ rom = db_rom_handler.get_rom(id) if not rom: raise RomNotFoundInDatabaseException(id) - filename = request.headers.get("x-upload-filename", "") - manuals_path = f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/manual" file_location = Path(f"{manuals_path}/{rom.id}.pdf") log.info(f"Uploading {hl(str(file_location))}") @@ -779,31 +845,32 @@ async def add_rom_manuals(request: Request, id: int): db_rom_handler.update_rom(id, {"path_manual": path_manual}) - return Response(status_code=status.HTTP_201_CREATED) + return Response() -@protected_route(router.post, "/delete", [Scope.ROMS_WRITE]) +@protected_route( + router.post, + "/delete", + [Scope.ROMS_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) async def delete_roms( request: Request, + roms: Annotated[ + list[int], + Body(description="List of rom ids to delete from database."), + ], + delete_from_fs: Annotated[ + list[int], + Body( + description="List of rom ids to delete from filesystem.", + default_factory=list, + ), + ], ) -> MessageResponse: - """Delete roms endpoint + """Delete roms.""" - Args: - request (Request): Fastapi Request object. - { - "roms": List of rom's ids to delete - } - delete_from_fs (bool, optional): Flag to delete rom from filesystem. Defaults to False. - - Returns: - MessageResponse: Standard message response - """ - - data: dict = await request.json() - roms_ids: list = data["roms"] - delete_from_fs: list = data["delete_from_fs"] - - for id in roms_ids: + for id in roms: rom = db_rom_handler.get_rom(id) if not rom: @@ -832,11 +899,30 @@ async def delete_roms( status_code=status.HTTP_404_NOT_FOUND, detail=error ) from exc - return {"msg": f"{len(roms_ids)} roms deleted successfully!"} + return {"msg": f"{len(roms)} roms deleted successfully!"} -@protected_route(router.put, "/{id}/props", [Scope.ROMS_USER_WRITE]) -async def update_rom_user(request: Request, id: int) -> RomUserSchema: +@protected_route( + router.put, + "/{id}/props", + [Scope.ROMS_USER_WRITE], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +async def update_rom_user( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + update_last_played: Annotated[ + bool, + Body(description="Whether to update the last played date."), + ] = False, + remove_last_played: Annotated[ + bool, + Body(description="Whether to remove the last played date."), + ] = False, +) -> RomUserSchema: + """Update rom data associated to the current user.""" + + # TODO: Migrate to native FastAPI body parsing. data = await request.json() rom_user_data = data.get("data", {}) @@ -868,9 +954,9 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema: if field in rom_user_data } - if data.get("update_last_played", False): + if update_last_played: cleaned_data.update({"last_played": datetime.now(timezone.utc)}) - elif data.get("remove_last_played", False): + elif remove_last_played: cleaned_data.update({"last_played": None}) rom_user = db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data) @@ -882,11 +968,14 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema: router.get, "files/{id}", [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, ) async def get_romfile( request: Request, - id: int, + id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)], ) -> RomFileSchema: + """Retrieve a rom file by ID.""" + file = db_rom_handler.get_rom_file_by_id(id) if not file: raise HTTPException( @@ -901,22 +990,14 @@ async def get_romfile( router.get, "files/{id}/content/{file_name}", [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, ) async def get_romfile_content( request: Request, - id: int, - file_name: str, + id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)], + file_name: Annotated[str, PathVar(description="File name to download")], ): - """Download rom file endpoint - - Args: - request (Request): Fastapi Request object - id (int): RomFile internal id - file_name (str): What to name the file when downloading - - Returns: - FileResponse: Returns the response with headers - """ + """Download a rom file.""" current_username = ( request.user.username if request.user.is_authenticated else "unknown" diff --git a/poetry.lock b/poetry.lock index 9243a4cc1..84ee2ec14 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -766,24 +766,24 @@ probabilistic = ["pyprobables (>=0.6,<0.7)"] [[package]] name = "fastapi" -version = "0.115.6" +version = "0.115.14" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"}, - {file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"}, + {file = "fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca"}, + {file = "fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.42.0" +starlette = ">=0.40.0,<0.47.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "fastapi-pagination" @@ -4455,4 +4455,4 @@ test = ["fakeredis", "pytest", "pytest-asyncio", "pytest-env", "pytest-mock", "p [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "b2a8d562ee0d10d98fb31df079db168c57360de0df9507eeaacb3b0fd097331a" +content-hash = "94c9ec6caf38454d244fccefa6ff4222745d4adf727e11b240dc839385f94986" diff --git a/pyproject.toml b/pyproject.toml index 1cd0e0b6d..69f7489fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "colorama ~= 0.4", "defusedxml ~= 0.7.1", "emoji == 2.10.1", - "fastapi == 0.115.6", + "fastapi ~= 0.115", "fastapi-pagination (>=0.12.34,<0.13.0)", "gunicorn == 23.0.0", "httpx ~= 0.27",