diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml new file mode 100644 index 0000000..f2e99da --- /dev/null +++ b/.github/workflows/ci-pytest.yml @@ -0,0 +1,34 @@ +name: CI Pytest +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] +jobs: + build: + runs-on: "ubuntu-latest" + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v5 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-cov mypy ruff + - name: Lint with Ruff + run: ruff check src/pyxivapi + - name: Type check with mypy + run: mypy src/pyxivapi + - name: Run tests + run: pytest -v --cov=pyxivapi --cov-report=xml + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..8e5ed93 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,24 @@ +name: Publish to PyPI +on: + release: + types: [published] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Build package + run: python -m build + - name: Publish to PyPI + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 856285c..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Publish to PyPI - -on: - release: - types: [created] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v1 - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* diff --git a/.gitignore b/.gitignore index f5430aa..4626c23 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ parts/ sdist/ var/ diff --git a/README.md b/README.md index 131f8c1..7fcf74e 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,94 @@ # pyxivapi -An asynchronous Python client for XIVAPI -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/741f410aefad4fa69cc6925ff5d83b4b)](https://www.codacy.com/manual/Yandawl/xivapi-py?utm_source=github.com&utm_medium=referral&utm_content=xivapi/xivapi-py&utm_campaign=Badge_Grade) -[![PyPI version](https://badge.fury.io/py/pyxivapi.svg)](https://badge.fury.io/py/pyxivapi) -[![Python 3.6](https://img.shields.io/badge/python-3.6-green.svg)](https://www.python.org/downloads/release/python-360/) +[![PyPI - Version](https://img.shields.io/pypi/v/pyxivapi.svg)](https://pypi.org/project/pyxivapi) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyxivapi.svg)](https://pypi.org/project/pyxivapi) -## Requirements -```python -python>=3.6.0 -asyncio -aiohttp -``` +An asynchronous Python client library for working with [XIVAPI v2](https://v2.xivapi.com/), providing access to Final Fantasy XIV game data. It lets you fetch, search, and work with FFXIV data using a clean, modern Python interface. + +If you need help or run into any issues, please [open an issue](https://github.com/xivapi/xivapi-py/issues) on GitHub or join the [XIVAPI Discord server](https://discord.gg/MFFVHWC) for support. ## Installation -```python + +```bash pip install pyxivapi ``` -## Supported API end points - -* /character/search -* /character/id -* /freecompany/search -* /freecompany/id -* /linkshell/search -* /linkshell/id -* /pvpteam/search -* /pvpteam/id -* /index/search (e.g. recipe, item, action, pvpaction, mount, e.t.c.) -* /index/id -* /lore/search -* /lodestone/worldstatus - -## Documentation - - -## Example -```python -import asyncio -import logging - -import aiohttp -import pyxivapi -from pyxivapi.models import Filter, Sort - - -async def fetch_example_results(): - client = pyxivapi.XIVAPIClient(api_key="your_key_here") - - # Search Lodestone for a character - character = await client.character_search( - world="odin", - forename="lethys", - surname="lightpaw" - ) - - # Get a character by Lodestone ID with extended data & include their Free Company information, if it has been synced. - character = await client.character_by_id( - lodestone_id=8255311, - extended=True, - include_freecompany=True - ) - - # Search Lodestone for a free company - freecompany = await client.freecompany_search( - world="gilgamesh", - name="Elysium" - ) - - # Item search with paging - item = await client.index_search( - name="Eden", - indexes=["Item"], - columns=["ID", "Name"], - filters=[ - Filter("LevelItem", "gt", 520) - ], - sort=Sort("LevelItem", False), - page=0, - per_page=10 - ) - - # Fuzzy search XIVAPI game data for a recipe by name. Results will be in English. - recipe = await client.index_search( - name="Crimson Cider", - indexes=["Recipe"], - columns=["ID", "Name", "Icon", "ItemResult.Description"] - ) - - # Fuzzy search XIVAPI game data for a recipe by name. Results will be in French. - recipe = await client.index_search( - name="Cidre carmin", - indexes=["Recipe"], - columns=["ID", "Name", "Icon", "ItemResult.Description"], - language="fr" - ) - - # Get an item by its ID (Omega Rod) and return the data in German - item = await client.index_by_id( - index="Item", - content_id=23575, - columns=["ID", "Name", "Icon", "ItemUICategory.Name"], - language="de" - ) - - filters = [ - Filter("ClassJobLevel", "gte", 0) - ] - - # Get non-npc actions matching a given term (Defiance) - action = await client.index_search( - name="Defiance", - indexes=["Action", "PvPAction", "CraftAction"], - columns=["ID", "Name", "Icon", "Description", "ClassJobCategory.Name", "ClassJobLevel", "ActionCategory.Name"], - filters=filters, - string_algo="match" - ) - - # Search ingame data for matches against a given query. Includes item, minion, mount & achievement descriptions, quest dialog & more. - lore = await client.lore_search( - query="Shiva", - language="fr" - ) - - # Search for an item using specific filters - filters = [ - Filter("LevelItem", "gte", 100) - ] - - sort = Sort("LevelItem", True) - - item = await client.index_search( - name="Omega Rod", - indexes=["Item"], - columns=["ID", "Name", "Icon", "Description", "LevelItem"], - filters=filters, - sort=sort, - language="de" - ) - - await client.session.close() - - -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO, format='%(message)s', datefmt='%H:%M') - loop = asyncio.get_event_loop() - loop.run_until_complete(fetch_example_results()) +```py +from pyxivapi import XIVAPI + +# Basic instance +xiv = XIVAPI() + +# With custom options +xiv_custom = XIVAPI( + version="7.0" # specify game version + language="ja" # ja, en, de, fr + verbose=True # enable debug logging +) +``` + +## Basic Usage + +```py +xiv.items.get(1) +print(item["fields"]["Name"]) # "Gil" (or equivalent in your language) +``` +```py +params = { "query": 'Name~"gil"', "sheets": "Item" } +results = xiv.search(params) +print(results[0]) +""" +Example output: +{ + "score": 1, + "sheet": "Item", + "row_id": 1, + "fields": { + "Icon": { + "id": 65002, + "path": "ui/icon/065000/065002.tex", + "path_hr1": "ui/icon/065000/065002_hr1.tex" + }, + "Name": "Gil", + "Singular": "gil" + } +} +""" ``` + +## Contributing + +Contributions of any kind are welcome - bug fixes, improvements, new features, or documentation updates. + +### Getting Started + +```bash +git clone https://github.com/miichom/pyxivapi.git +cd pyxivapi +hatch env create dev +``` + +### Run the checks: + +```bash +hatch run dev:lint +hatch run dev:types +hatch run dev:test +``` + +### Before Opening a PR + +Please make sure: + +- All tests pass (`hatch run dev:test`) +- Type checking passes (`hatch run dev:types`) +- Linting passes (`hatch run dev:lint`) +- Your changes are clearly described in the PR +- Any relevant issues are referenced + +If you want to discuss an idea before implementing it, feel free to open an issue. + +## License + +`pyxivapi` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..758e10f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pyxivapi" +version = "0.6.0" +description="An asynchronous Python client for XIVAPI" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + { name = "Lethys", email = "seraymericbot@gmail.com" }, + { name = "miichom", email = "hello@cammy.xyz" } +] +keywords = ["xivapi", "final fantasy xiv", "ffxiv", "api client"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries" +] +dependencies = [ + "requests>=2.31.0", + "pydantic>=2.6.0" +] + +[project.urls] +Documentation = "https://github.com/miichom/pyxivapi#readme" +Issues = "https://github.com/miichom/pyxivapi/issues" +Source = "https://github.com/miichom/pyxivapi" + +[tool.hatch.envs.dev] +dependencies = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "mypy>=1.0.0", + "ruff>=0.3.0", + "types-requests" +] + +[tool.hatch.envs.dev.scripts] +test = "pytest -v" +lint = "ruff check src/pyxivapi" +types = "mypy --install-types --non-interactive src/pyxivapi" + +[tool.coverage.run] +source_pkgs = ["pyxivapi", "tests"] +branch = true +parallel = true + +[tool.coverage.paths] +pyxivapi = ["src/pyxivapi", "*/pyxivapi/src/pyxivapi"] +tests = ["tests", "*/pyxivapi/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:" +] + +[tool.ruff] +line-length = 100 +target-version = "py38" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = "pyxivapi.lib.models" +disable_error_code = ["assignment", "type-arg"] diff --git a/pyxivapi/__init__.py b/pyxivapi/__init__.py deleted file mode 100644 index bbcda5f..0000000 --- a/pyxivapi/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -__title__ = 'pyxivapi' -__author__ = 'Lethys' -__license__ = 'MIT' -__copyright__ = 'Copyright 2019 (c) Lethys' -__version__ = '0.5.1' - -from .client import XIVAPIClient -from .exceptions import ( - XIVAPIForbidden, - XIVAPIBadRequest, - XIVAPINotFound, - XIVAPIServiceUnavailable, - XIVAPIInvalidLanguage, - XIVAPIInvalidIndex, - XIVAPIInvalidColumns, - XIVAPIInvalidFilter, - XIVAPIInvalidWorlds, - XIVAPIInvalidDatacenter, - XIVAPIError, - XIVAPIInvalidAlgo -) diff --git a/pyxivapi/client.py b/pyxivapi/client.py deleted file mode 100644 index 467af90..0000000 --- a/pyxivapi/client.py +++ /dev/null @@ -1,425 +0,0 @@ -import logging -from typing import List, Optional - -from aiohttp import ClientSession - -from .exceptions import ( - XIVAPIBadRequest, XIVAPIForbidden, XIVAPINotFound, XIVAPIServiceUnavailable, - XIVAPIInvalidLanguage, XIVAPIError, XIVAPIInvalidIndex, XIVAPIInvalidColumns, - XIVAPIInvalidAlgo -) -from .decorators import timed -from .models import Filter, Sort - -__log__ = logging.getLogger(__name__) - - -class XIVAPIClient: - """ - Asynchronous client for accessing XIVAPI's endpoints. - Parameters - ------------ - api_key: str - The API key used for identifying your application with XIVAPI.com. - session: Optional[ClientSession] - Optionally include your aiohttp session - """ - base_url = "https://xivapi.com" - languages = ["en", "fr", "de", "ja"] - - def __init__(self, api_key: str, session: Optional[ClientSession] = None) -> None: - self.api_key = api_key - self._session = session - - self.base_url = "https://xivapi.com" - self.languages = ["en", "fr", "de", "ja"] - self.string_algos = [ - "custom", "wildcard", "wildcard_plus", "fuzzy", "term", "prefix", "match", "match_phrase", - "match_phrase_prefix", "multi_match", "query_string" - ] - - @property - def session(self) -> ClientSession: - if self._session is None or self._session.closed: - self._session = ClientSession() - return self._session - - @timed - async def character_search(self, world, forename, surname, page=1): - """|coro| - Search for character data directly from the Lodestone. - Parameters - ------------ - world: str - The world that the character is attributed to. - forename: str - The character's forename. - surname: str - The character's surname. - Optional[page: int] - The page of results to return. Defaults to 1. - """ - url = f'{self.base_url}/character/search?name={forename}%20{surname}&server={world}&page={page}&private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - @timed - async def character_by_id(self, lodestone_id: int, extended=False, include_achievements=False, include_minions_mounts=False, include_classjobs=False, include_friendslist=False, include_freecompany=False, include_freecompany_members=False, include_pvpteam=False, language="en"): - """|coro| - Request character data from XIVAPI.com - Please see XIVAPI documentation for more information about character sync state https://xivapi.com/docs/Character#character - Parameters - ------------ - lodestone_id: int - The character's Lodestone ID. - """ - - params = { - "private_key": self.api_key, - "language": language - } - - if language.lower() not in self.languages: - raise XIVAPIInvalidLanguage(f'"{language}" is not a valid language code for XIVAPI.') - - if extended is True: - params["extended"] = 1 - - data = [] - if include_achievements is True: - data.append("AC") - - if include_minions_mounts is True: - data.append("MIMO") - - if include_friendslist is True: - data.append("FR") - - if include_classjobs is True: - data.append("CJ") - - if include_freecompany is True: - data.append("FC") - - if include_freecompany_members is True: - data.append("FCM") - - if include_pvpteam is True: - data.append("PVP") - - if len(data) > 0: - params["data"] = ",".join(data) - - url = f'{self.base_url}/character/{lodestone_id}' - async with self.session.get(url, params=params) as response: - return await self.process_response(response) - - @timed - async def freecompany_search(self, world, name, page=1): - """|coro| - Search for Free Company data directly from the Lodestone. - Parameters - ------------ - world: str - The world that the Free Company is attributed to. - name: str - The Free Company's name. - Optional[page: int] - The page of results to return. Defaults to 1. - """ - url = f'{self.base_url}/freecompany/search?name={name}&server={world}&page={page}&private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - @timed - async def freecompany_by_id(self, lodestone_id: int, extended=False, include_freecompany_members=False): - """|coro| - Request Free Company data from XIVAPI.com by Lodestone ID - Please see XIVAPI documentation for more information about Free Company info at https://xivapi.com/docs/Free-Company#profile - Parameters - ------------ - lodestone_id: int - The Free Company's Lodestone ID. - """ - - params = { - "private_key": self.api_key - } - - if extended is True: - params["extended"] = 1 - - data = [] - if include_freecompany_members is True: - data.append("FCM") - - if len(data) > 0: - params["data"] = ",".join(data) - - url = f'{self.base_url}/freecompany/{lodestone_id}' - async with self.session.get(url, params=params) as response: - return await self.process_response(response) - - @timed - async def linkshell_search(self, world, name, page=1): - """|coro| - Search for Linkshell data directly from the Lodestone. - Parameters - ------------ - world: str - The world that the Linkshell is attributed to. - name: str - The Linkshell's name. - Optional[page: int] - The page of results to return. Defaults to 1. - """ - url = f'{self.base_url}/linkshell/search?name={name}&server={world}&page={page}&private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - @timed - async def linkshell_by_id(self, lodestone_id: int): - """|coro| - Request Linkshell data from XIVAPI.com by Lodestone ID - Parameters - ------------ - lodestone_id: int - The Linkshell's Lodestone ID. - """ - url = f'{self.base_url}/linkshell/{lodestone_id}?private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - @timed - async def pvpteam_search(self, world, name, page=1): - """|coro| - Search for PvPTeam data directly from the Lodestone. - Parameters - ------------ - world: str - The world that the PvPTeam is attributed to. - name: str - The PvPTeam's name. - Optional[page: int] - The page of results to return. Defaults to 1. - """ - url = f'{self.base_url}/pvpteam/search?name={name}&server={world}&page={page}&private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - @timed - async def pvpteam_by_id(self, lodestone_id): - """|coro| - Request PvPTeam data from XIVAPI.com by Lodestone ID - Parameters - ------------ - lodestone_id: str - The PvPTeam's Lodestone ID. - """ - url = f'{self.base_url}/pvpteam/{lodestone_id}?private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - @timed - async def index_search(self, name, indexes=(), columns=(), filters: List[Filter] = (), sort: Sort = None, page=0, per_page=10, language="en", string_algo="match"): - """|coro| - Search for data from on specific indexes. - Parameters - ------------ - name: str - The name of the item to retrieve the recipe data for. - indexes: list - A named list of indexes to search XIVAPI. At least one must be specified. - e.g. ["Recipe", "Item"] - Optional[columns: list] - A named list of columns to return in the response. ID, Name, Icon & ItemDescription will be returned by default. - e.g. ["ID", "Name", "Icon"] - Optional[filters: list] - A list of type Filter. Filter must be initialised with Field, Comparison (e.g. lt, lte, gt, gte) and value. - e.g. filters = [ Filter("LevelItem", "gte", 100) ] - Optional[sort: Sort] - The name of the column to sort on. - Optional[page: int] - The page of results to return. Defaults to 1. - Optional[language: str] - The two character length language code that indicates the language to return the response in. Defaults to English (en). - Valid values are "en", "fr", "de" & "ja" - Optional[string_algo: str] - The search algorithm to use for string matching (default = "match") - Valid values are "custom", "wildcard", "wildcard_plus", "fuzzy", "term", "prefix", "match", "match_phrase", - "match_phrase_prefix", "multi_match", "query_string" - """ - - if len(indexes) == 0: - raise XIVAPIInvalidIndex("Please specify at least one index to search for, e.g. [\"Recipe\"]") - - if language.lower() not in self.languages: - raise XIVAPIInvalidLanguage(f'"{language}" is not a valid language code for XIVAPI.') - - if len(columns) == 0: - raise XIVAPIInvalidColumns("Please specify at least one column to return in the resulting data.") - - if string_algo not in self.string_algos: - raise XIVAPIInvalidAlgo(f'"{string_algo}" is not a supported string_algo for XIVAPI') - - body = { - "indexes": ",".join(list(set(indexes))), - "columns": "ID", - "body": { - "query": { - "bool": { - "should": [{ - string_algo: { - "NameCombined_en": { - "query": name, - "fuzziness": "AUTO", - "prefix_length": 1, - "max_expansions": 50 - } - } - }, { - string_algo: { - "NameCombined_de": { - "query": name, - "fuzziness": "AUTO", - "prefix_length": 1, - "max_expansions": 50 - } - } - }, { - string_algo: { - "NameCombined_fr": { - "query": name, - "fuzziness": "AUTO", - "prefix_length": 1, - "max_expansions": 50 - } - } - }, { - string_algo: { - "NameCombined_ja": { - "query": name, - "fuzziness": "AUTO", - "prefix_length": 1, - "max_expansions": 50 - } - } - }] - } - }, - "from": page, - "size": per_page - } - } - - if len(columns) > 0: - body["columns"] = ",".join(list(set(columns))) - - if len(filters) > 0: - filts = [] - for f in filters: - filts.append({ - "range": { - f.Field: { - f.Comparison: f.Value - } - } - }) - - body["body"]["query"]["bool"]["filter"] = filts - - if sort: - body["body"]["sort"] = [{ - sort.Field: "asc" if sort.Ascending else "desc" - }] - - url = f'{self.base_url}/search?language={language}&private_key={self.api_key}' - async with self.session.post(url, json=body) as response: - return await self.process_response(response) - - @timed - async def index_by_id(self, index, content_id: int, columns=(), language="en"): - """|coro| - Request data from a given index by ID. - Parameters - ------------ - index: str - The index to which the content is attributed. - content_id: int - The ID of the content - Optional[columns: list] - A named list of columns to return in the response. ID, Name, Icon & ItemDescription will be returned by default. - e.g. ["ID", "Name", "Icon"] - Optional[language: str] - The two character length language code that indicates the language to return the response in. Defaults to English (en). - Valid values are "en", "fr", "de" & "ja" - """ - if index == "": - raise XIVAPIInvalidIndex("Please specify an index to search on, e.g. \"Item\"") - - if len(columns) == 0: - raise XIVAPIInvalidColumns("Please specify at least one column to return in the resulting data.") - - params = { - "private_key": self.api_key, - "language": language - } - - if len(columns) > 0: - params["columns"] = ",".join(list(set(columns))) - - url = f'{self.base_url}/{index}/{content_id}' - async with self.session.get(url, params=params) as response: - return await self.process_response(response) - - @timed - async def lore_search(self, query, language="en"): - """|coro| - Search cutscene subtitles, quest dialog, item, achievement, mount & minion descriptions and more for any text that matches query. - Parameters - ------------ - query: str - The text to search game content for. - Optional[language: str] - The two character length language code that indicates the language to return the response in. Defaults to English (en). - Valid values are "en", "fr", "de" & "ja" - """ - params = { - "private_key": self.api_key, - "language": language, - "string": query - } - - url = f'{self.base_url}/lore' - async with self.session.get(url, params=params) as response: - return await self.process_response(response) - - @timed - async def lodestone_worldstatus(self): - """|coro| - Request world status post from the Lodestone. - """ - url = f'{self.base_url}/lodestone/worldstatus?private_key={self.api_key}' - async with self.session.get(url) as response: - return await self.process_response(response) - - async def process_response(self, response): - __log__.info(f'{response.status} from {response.url}') - - if response.status == 200: - return await response.json() - - if response.status == 400: - raise XIVAPIBadRequest("Request was bad. Please check your parameters.") - - if response.status == 401: - raise XIVAPIForbidden("Request was refused. Possibly due to an invalid API key.") - - if response.status == 404: - raise XIVAPINotFound("Resource not found.") - - if response.status == 500: - raise XIVAPIError("An internal server error has occured on XIVAPI.") - - if response.status == 503: - raise XIVAPIServiceUnavailable("Service is unavailable. This could be because the Lodestone is under maintenance.") diff --git a/pyxivapi/decorators.py b/pyxivapi/decorators.py deleted file mode 100644 index 772127e..0000000 --- a/pyxivapi/decorators.py +++ /dev/null @@ -1,16 +0,0 @@ -import logging -from functools import wraps -from time import time - -__log__ = logging.getLogger(__name__) - - -def timed(func): - """This decorator prints the execution time for the decorated function.""" - @wraps(func) - async def wrapper(*args, **kwargs): - start = time() - result = await func(*args, **kwargs) - __log__.info("{} executed in {}s".format(func.__name__, round(time() - start, 2))) - return result - return wrapper diff --git a/pyxivapi/exceptions.py b/pyxivapi/exceptions.py deleted file mode 100644 index efcf9e9..0000000 --- a/pyxivapi/exceptions.py +++ /dev/null @@ -1,82 +0,0 @@ -class XIVAPIForbidden(Exception): - """ - XIVAPI Forbidden Request error - """ - pass - - -class XIVAPIBadRequest(Exception): - """ - XIVAPI Bad Request error - """ - pass - - -class XIVAPINotFound(Exception): - """ - XIVAPI not found error - """ - pass - - -class XIVAPIServiceUnavailable(Exception): - """ - XIVAPI service unavailable error - """ - pass - - -class XIVAPIInvalidLanguage(Exception): - """ - XIVAPI invalid language error - """ - pass - - -class XIVAPIInvalidIndex(Exception): - """ - XIVAPI invalid index error - """ - pass - - -class XIVAPIInvalidColumns(Exception): - """ - XIVAPI invalid columns error - """ - pass - - -class XIVAPIInvalidFilter(Exception): - """ - XIVAPI invalid filter error - """ - pass - - -class XIVAPIInvalidWorlds(Exception): - """ - XIVAPI invalid world(s) error - """ - pass - - -class XIVAPIInvalidDatacenter(Exception): - """ - XIVAPI invalid datacenter error - """ - pass - - -class XIVAPIError(Exception): - """ - XIVAPI error - """ - pass - - -class XIVAPIInvalidAlgo(Exception): - """ - Invalid String Algo - """ - pass diff --git a/pyxivapi/models.py b/pyxivapi/models.py deleted file mode 100644 index 36653b3..0000000 --- a/pyxivapi/models.py +++ /dev/null @@ -1,29 +0,0 @@ -from .exceptions import XIVAPIInvalidFilter - - -class Filter: - """ - Model class for DQL filters - """ - - comparisons = ["gt", "gte", "lt", "lte"] - - def __init__(self, field: str, comparison: str, value: int): - comparison = comparison.lower() - - if comparison not in self.comparisons: - raise XIVAPIInvalidFilter(f'"{comparison}" is not a valid DQL filter comparison.') - - self.Field = field - self.Comparison = comparison - self.Value = value - - -class Sort: - """ - Model class for sort field - """ - - def __init__(self, field: str, ascending: bool): - self.Field = field - self.Ascending = ascending diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7a899a0..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -aiohttp -asyncio \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 4c44a0f..0000000 --- a/setup.py +++ /dev/null @@ -1,40 +0,0 @@ -import re - -import setuptools - -with open('requirements.txt') as f: - REQUIREMENTS = f.readlines() - -with open('README.md') as f: - README = f.read() - -with open('pyxivapi/__init__.py') as f: - VERSION = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) - -setuptools.setup( - name='pyxivapi', - author='Lethys', - author_email='seraymericbot@gmail.com', - url='https://github.com/xivapi/xivapi-py', - version=VERSION, - packages=['pyxivapi'], - license='MIT', - description='An asynchronous Python client for XIVAPI', - long_description=README, - long_description_content_type="text/markdown", - keywords='ffxiv xivapi', - include_package_data=True, - install_requires=REQUIREMENTS, - classifiers=[ - 'License :: OSI Approved :: MIT License', - 'Intended Audience :: Developers', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Topic :: Internet', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Utilities', - ] -) diff --git a/src/pyxivapi/__init__.py b/src/pyxivapi/__init__.py new file mode 100644 index 0000000..090b140 --- /dev/null +++ b/src/pyxivapi/__init__.py @@ -0,0 +1,4 @@ +from .client import XIVAPI +from .utils import CustomError + +__all__ = ["XIVAPI", "CustomError"] diff --git a/src/pyxivapi/client.py b/src/pyxivapi/client.py new file mode 100644 index 0000000..18e8dbd --- /dev/null +++ b/src/pyxivapi/client.py @@ -0,0 +1,35 @@ +from typing import Any, Dict, Unpack +from .lib.models import (SearchQuery, VersionQuery, RowReaderQuery, SearchResponse, XIVAPIOptions) +from .lib.sheets import Sheet, Sheets +from .lib.assets import Assets +from .lib.versions import Versions +from .utils import request, CustomError + +class XIVAPI: + """Python wrapper for the XIVAPI v2 API.""" + def __init__(self, **options: Unpack[XIVAPIOptions]) -> None: + self.options = XIVAPIOptions(**options) + + # Typed endpoints + self.achievements = Sheet("Achievement", **self.options) + self.minions = Sheet("Companion", **self.options) + self.mounts = Sheet("Mount", **self.options) + self.items = Sheet("Item", **self.options) + + # Raw endpoints + self.assets = lambda: Assets() + self.sheets = lambda: Sheets(**self.options) + self.versions = lambda: [v.names[0] for v in Versions().all().versions] + + def search(self, params: Dict[str, Any] | SearchQuery | VersionQuery | RowReaderQuery) -> SearchResponse: + """ + Fetch information about rows matching the provided search query (`GET /search`). + + See: https://v2.xivapi.com/api/docs#tag/search/get/search + """ + if isinstance(params, dict): + params = SearchQuery(**params) + data, errors = request(path="/search", params=params.model_dump(exclude_none=True), options=self.options) + if errors: + raise CustomError(errors[0]["message"]) + return SearchResponse(**data) diff --git a/src/pyxivapi/lib/assets.py b/src/pyxivapi/lib/assets.py new file mode 100644 index 0000000..7c4c8cb --- /dev/null +++ b/src/pyxivapi/lib/assets.py @@ -0,0 +1,36 @@ +from typing import Dict, Any +from .models import AssetQuery, MapPath, VersionQuery +from ..utils import request, CustomError + +class Assets: + """ + Endpoints for accessing game data on a file-by-file basis. Commonly useful for fetching icons or other textures. + + See https://v2.xivapi.com/api/docs#tag/assets + """ + def get(self, params: AssetQuery) -> bytes: + """ + Read an asset from the game at the specified path, converting it into a usable format (`GET /asset`). + + See: https://v2.xivapi.com/api/docs#tag/assets/get/asset + """ + if isinstance(params, dict): + params = AssetQuery(**params) + data, errors = request(path="/asset", params=params.model_dump(exclude_none=True)) + if errors: + raise CustomError(errors[0]["message"]) + return data.get("data", data) + + def map(self, params: MapPath | VersionQuery | Dict[str, Any]) -> bytes: + """ + Retrieve the specified map, composing it from split source files if necessary (`GET /asset/map`). + + See: https://v2.xivapi.com/api/docs#tag/assets/get/asset/map/{territory}/{index} + """ + if isinstance(params, dict): + # MapPath + VersionQuery + {"format": ...} + params = {k: v for k, v in params.items()} + data, errors = request(path="/asset",params=params) + if errors: + raise CustomError(errors[0]["message"]) + return data.get("data", data) \ No newline at end of file diff --git a/src/pyxivapi/lib/models.py b/src/pyxivapi/lib/models.py new file mode 100644 index 0000000..a80e6b7 --- /dev/null +++ b/src/pyxivapi/lib/models.py @@ -0,0 +1,271 @@ +from pydantic import BaseModel +from typing import Dict, List, Optional, Union, Any, TypedDict, NotRequired +from enum import Enum + +class VersionQuery(BaseModel): + """ + Query parameters accepted by endpoints that interact with versioned game data. + + See: https://v2.xivapi.com/api/docs#model/versionquery + """ + version: Optional[str] = None + """Game version to utilise for this query.""" + +class SchemaFormat(str, Enum): + """See: https://v2.xivapi.com/api/docs#model/schemaformat""" + jpg = "jpg" + png = "png" + webp = "webp" + +class AssetQuery(BaseModel): + """ + Query parameters accepted by the asset endpoint. + + See: https://v2.xivapi.com/api/docs#model/assetquery + """ + format: SchemaFormat | str + path: str + """Game path of the asset to retrieve. E.g. `ui/icon/051000/051474_hr1.tex`""" + + +class ErrorResponse(BaseModel): + """ + General purpose error response structure. + + See: https://v2.xivapi.com/api/docs#model/errorresponse + """ + code: int + message: str + """Description of what went wrong.""" + +# status code + +class MapPath(BaseModel): + """ + Path segments expected by the asset map endpoint. + + See: https://v2.xivapi.com/api/docs#model/mappath + """ + index: str + """ + Index of the map within the territory. This invariably takes the form of a two-digit zero-padded number. See Map's Id field for examples of possible combinations of `territory` and `index`. + E.g. `00` + """ + territory: str + """ + Territory of the map to be retrieved. This typically takes the form of 4 characters, `[letter][number][letter][number]`. See Map's Id field for examples of possible combinations of `territory` and `index`. + E.g. `s1d1` + """ + +QueryString = Union[str, List[str], Dict[str,str|int|bool], None] + +class SearchQuery(BaseModel): + """ + Query paramters accepted by the search endpoint. + + See: https://v2.xivapi.com/api/docs#model/searchquery + """ + cursor: Optional[str] = None + """Continuation token to retrieve further results from a prior search request. If specified, takes priority over query.""" + limit: Optional[int] = None + """Maximum number of rows to return. To paginate, provide the cursor token provided in `next` to the `cursor` parameter.""" + query: QueryString = None + """ + A query string for searching excel data. + Queries are formed of clauses, which take the basic form of `[specifier][operation][value]`, i.e. `Name="Example"`. Multiple clauses may be specified by seperating them with whitespace, i.e. `Foo=1 Bar=2`. + + See: https://v2.xivapi.com/docs/guides/search/#query + """ + sheets: Optional[str] = None + """List of excel sheets that the query should be run against. At least one must be specified if not querying a cursor.""" + +class SchemaLanguage(str, Enum): + """See: https://v2.xivapi.com/api/docs#model/schemalanguage""" + none = "none" + en = "en" + ja = "ja" + de = "de" + fr = "fr" + chs = "chs" + cht = "cht" + kr = "kr" + +SchemaSpecifier = str +FilterString = Union[str, List[str]] + +class RowReaderQuery(BaseModel): + """ + Query parameters accepted by endpoints that retrieve excel row data. + + See: https://v2.xivapi.com/api/docs#model/rowreaderquery + """ + fields: Optional[FilterString] = None + """Comma-separated list of field paths to select.""" + language: Optional[Union[str, SchemaLanguage]] = None + """Language to read row data with.""" + schema: Optional[SchemaSpecifier] = None # pyright: ignore[reportIncompatibleMethodOverride] + """Schema specifier for row data.""" + transient: Optional[FilterString] = None + """Transient row field selection.""" + +class SearchResult(BaseModel): + """ + Result found by a search query. + + See: https://v2.xivapi.com/api/docs#model/searchresult + """ + fields: dict[str, Any] + row_id: int + """ID of this row.""" + score: float + """ + Relevance score for this entry. + These values only loosely represent the relevance of an entry to the search query. No guarantee is given that the discrete values, nor resulting sort order, will remain stable. + """ + sheet: SchemaSpecifier + """Excel sheet this result was found in.""" + subrow_id: Optional[int] = None + """Subrow ID of this row, when relevant.""" + transient: Optional[dict[str, Any]] = None + """Field values for this row's transient row, if any is present, according to the current schema and transient filter.""" + +class SearchResponse(BaseModel): + """ + Response structure for the search endpoint. + + See: https://v2.xivapi.com/api/docs#model/searchresponse + """ + results: List[SearchResult] + schema: SchemaSpecifier # pyright: ignore[reportIncompatibleMethodOverride] + next: Optional[str] = None + +class SheetMetadata(BaseModel): + """ + Metadata about a single sheet. + + See: https://v2.xivapi.com/api/docs#model/sheetmetadata + """ + name: str + """The name of the sheet.""" + +class ListResponse(BaseModel): + """ + Response structure for the list endpoint. + + See: https://v2.xivapi.com/api/docs#model/listresponse + """ + sheets: List[SheetMetadata] + """List of sheets known to the API.""" + +class SheetQuery(BaseModel): + """ + Query parameters accepted by the sheet endpoint. + + See: https://v2.xivapi.com/api/docs#model/sheetquery + """ + after: Optional[SchemaSpecifier] = None + """Fetch rows after the specified row. Behavior is undefined if both `rows` and `after` are provided.""" + limit: Optional[int] = None + """Maximum number of rows to return. To paginate, provide the last returned row to the next request's `after` parameter.""" + rows: Optional[str] = None + """ + Rows to fetch from the sheet, as a comma-separated list. Behavior is undefined if both `rows` and `after` are provided. + """ + +class SheetPath(BaseModel): + """ + Path variables accepted by the sheet endpoint. + + See: https://v2.xivapi.com/api/docs#model/sheetpath + """ + sheet: SchemaSpecifier + """Name of the sheet to read.""" + +class RowResult(BaseModel): + """ + Row retrieved by the sheet endpoint. + + See: https://v2.xivapi.com/api/docs#model/rowresult + """ + fields: dict[str, Any] + row_id: int + """ID of this row.""" + subrow_id: Optional[int] = None + """Subrow ID of this row, when relevant.""" + transient: Optional[dict[str, Any]] = None + """Field values for this row's transient row, if any is present, according to the current schema and transient filter.""" + +class SheetResponse(BaseModel): + """ + Response structure for the sheet endpoint. + + See: https://v2.xivapi.com/api/docs#model/sheetresponse + """ + rows: List[RowResult] + """ + List of rows retrieved by the query. + + See: https://v2.xivapi.com/api/docs#model/rowresult + """ + schema: SchemaSpecifier # type: ignore - schema exists on BaseModel + """The canonical specifier for the schema used in this response.""" + +class RowPath(BaseModel): + """ + Path variables accepted by the row endpoint. + + See: https://v2.xivapi.com/api/docs#model/rowpath + """ + row: str + sheet: SchemaSpecifier + """Name of the sheet to read.""" + +class RowResponse(BaseModel): + """ + Response structure for the row endpoint. + + See: https://v2.xivapi.com/api/docs#model/rowresponse + """ + fields: dict[str, Any] + row_id: int + """ID of this row.""" + schema: SchemaSpecifier # pyright: ignore[reportIncompatibleMethodOverride] + """The canonical specifier for the schema used in this response.""" + subrow_id: Optional[int] = None + """Subrow ID of this row, when relevant.""" + transient: Optional[dict[str, Any]] = None + """Field values for this row's transient row, if any is present, according to the current schema and transient filter.""" + +class VersionMetadata(BaseModel): + """ + Metadata about a single version supported by the API. + + See: https://v2.xivapi.com/api/docs#model/versionmetadata + """ + names: List[str] + """Names associated with this version. Version names specified here are accepted by the `version` query parameter throughout the API.""" + +class VersionsResponse(BaseModel): + """ + Response structure for the versions endpoint. + + See: https://v2.xivapi.com/api/docs#model/versionsresponse + """ + versions: List[VersionMetadata] + """List of versions available in the API.""" + +class XIVAPIOptions(TypedDict,total=False): + version: NotRequired[str] + """ + All API endpoints that serve data derived from game files accept a `version` parameter. + If omitted, the version `latest` will be used + + See: https://v2.xivapi.com/docs/guides/pinning/#game-versions + """ + language: NotRequired[SchemaLanguage | str] + """ + Sheets with user-facing strings are commonly localised into all the languages supported by the game client. + + See: https://v2.xivapi.com/docs/guides/sheets/#language + """ + verbose: NotRequired[bool] \ No newline at end of file diff --git a/src/pyxivapi/lib/sheets.py b/src/pyxivapi/lib/sheets.py new file mode 100644 index 0000000..00f5752 --- /dev/null +++ b/src/pyxivapi/lib/sheets.py @@ -0,0 +1,75 @@ +from typing import Optional, Unpack +from pyxivapi.client import XIVAPIOptions +from .models import (RowReaderQuery, SheetQuery, RowResponse, SheetResponse, ListResponse, SchemaSpecifier) +from ..utils import request, CustomError + +class Sheet: + """ + Typed wrapper for a single XIVAPI sheet. + + See: https://v2.xivapi.com/api/docs#tag/sheets + """ + def __init__(self, sheet: SchemaSpecifier, **options: Unpack[XIVAPIOptions]) -> None: + self.type = sheet + self.options = XIVAPIOptions(**options) + + def get(self, row_id: str | int, params: Optional[RowReaderQuery] = None) -> RowResponse: + """ + Fetch a single row from the sheet (`GET /sheet/{sheet}/{row}`). + + See: https://v2.xivapi.com/api/docs#tag/sheets/get/sheet/{sheet}/{row} + """ + try: + row_id = str(row_id) + return Sheets(**self.options).get(self.type, row_id, params or RowReaderQuery()) + except Exception as e: + raise CustomError(str(e)) + + def list(self, params: Optional[SheetQuery] = None) -> SheetResponse: + """ + Fetches multiple rows from the sheet (`GET /sheet/{sheet}`). + + See: https://v2.xivapi.com/api/docs#tag/sheets/get/sheet/{sheet} + """ + try: + return Sheets(**self.options).list(self.type, params or SheetQuery()) + except Exception as e: + raise CustomError(str(e)) + +class Sheets: + """ + Raw endpoints for reading data from XIVAPI sheets. + + See: https://v2.xivapi.com/api/docs#tag/sheets + """ + def __init__(self, **options: Unpack[XIVAPIOptions]) -> None: + self.options = XIVAPIOptions(**options) + + def all(self) -> ListResponse: + """List all known sheets.""" + data, errors = request(path="/sheet", params={}, **self.options) + if errors: + raise CustomError(errors[0]["message"]) + return ListResponse(**data) + + def list(self, sheet: SchemaSpecifier, params: Optional[SheetQuery] = None) -> SheetResponse: + """Fetch multiple rows from a sheet.""" + if params is None: + params = SheetQuery() + elif isinstance(params, dict): + params = SheetQuery(**params) + data, errors = request(path=f"/sheet/{sheet}", params=params.model_dump(exclude_none=True), options=self.options) + if errors: + raise CustomError(errors[0]["message"]) + return SheetResponse(**data) + + def get(self, sheet: SchemaSpecifier, row: str, params: Optional[RowReaderQuery] = None) -> RowResponse: + """Fetch a single row from a sheet.""" + if params is None: + params = SheetQuery() + elif isinstance(params, dict): + params = SheetQuery(**params) + data, errors = request(path=f"/sheet/{sheet}/{row}", params=params.model_dump(exclude_none=True), options=self.options) + if errors: + raise CustomError(errors[0]["message"]) + return RowResponse(**data) \ No newline at end of file diff --git a/src/pyxivapi/lib/versions.py b/src/pyxivapi/lib/versions.py new file mode 100644 index 0000000..221484c --- /dev/null +++ b/src/pyxivapi/lib/versions.py @@ -0,0 +1,10 @@ +from .models import VersionsResponse +from ..utils import request, CustomError + +class Versions: + """Raw versions endpoint.""" + def all(self) -> VersionsResponse: + data, errors = request(path="/version", params={}) + if errors: + raise CustomError(errors[0]["message"]) + return VersionsResponse(**data) diff --git a/src/pyxivapi/py.typed b/src/pyxivapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pyxivapi/utils.py b/src/pyxivapi/utils.py new file mode 100644 index 0000000..a22cc26 --- /dev/null +++ b/src/pyxivapi/utils.py @@ -0,0 +1,60 @@ +import requests +from urllib.parse import urlencode, urljoin +from typing import Any, Dict, Optional, Tuple + +# The endpoint to use, kept at the top for quick changing (if needed) +endpoint = "https://v2.xivapi.com/api/" + +class CustomError(Exception): + def __init__(self, message: str, name: Optional[str] = None): + super().__init__(message) + self.name = name or "XIVAPIError" + self.message = message + +def request(*, path: str, params: Optional[Dict[str, Any]] = None, options: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], Optional[list[Any]]]: + url = urljoin(endpoint, path.lstrip("/")) + params = params or {} + options = options or {} + + # Moves the verbose, if provided, to the options dict + if not options.get("verbose") and "verbose" in params: + options["verbose"] = bool(params["verbose"]) + params.pop("verbose", None) + + # Flattens the dict params + flattened = { "query": " ", "fields": ",", "transient": "," } + for key, sep in flattened.items(): + if key in params and isinstance(params[key], list): + params[key] = sep.join(str(x) for x in params[key]) + + # Inject language/version defaults + if "language" not in params and options.get("language"): + params["language"] = options["language"] + if "version" not in params and options.get("version"): + params["version"] = options["version"] + + if params: + url = f"{url}?{urlencode(params)}" + + if options.get("verbose"): + print(f"[XIVAPI] Requesting {url} with params: {params}") + + response = requests.get(url) + + if options.get("verbose"): + print(f"[XIVAPI] Response {response.status_code} for {url} with params: {params}") + + if response.ok: + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + return response.json(), None + else: + # Binary data (icons, textures, etc.) + return { "data": response.content }, None + + try: + error_json = response.json() + except Exception: + error_json = { "message": "Unknown error", "code": response.status_code } + + return {}, [error_json] \ No newline at end of file diff --git a/tests/test_pyxivapi.py b/tests/test_pyxivapi.py new file mode 100644 index 0000000..38ce919 --- /dev/null +++ b/tests/test_pyxivapi.py @@ -0,0 +1,150 @@ +import pytest +from pyxivapi import XIVAPI, CustomError +from pyxivapi.lib.models import SearchResponse + +API_TIMEOUT = 10 # seconds + +# List/dict validators +def validate_search(result: SearchResponse): + assert result is not None + assert isinstance(result.results, list) + assert result.schema is not None + + if result.results: + first = result.results[0] + assert isinstance(first.row_id, int) + assert isinstance(first.score, (int, float)) + assert isinstance(first.sheet, str) + assert isinstance(first.fields, dict) + +def validate_item(result: SearchResponse, expected_sheet="Item"): + assert len(result.results) > 0 + + for item in result.results: + assert item.sheet == expected_sheet + assert isinstance(item.fields, dict) + + if "Name" in item.fields: + assert isinstance(item.fields["Name"], str) + assert len(item.fields["Name"]) > 0 + if "ID" in item.fields: + assert isinstance(item.fields["ID"], int) + assert len(item.fields["ID"]) > 0 + if "LevelItem" in item.fields: + assert isinstance(item.fields["LevelItem"], int) + assert item.fields["LevelItem"] >= 0 + +def validate_action(result: SearchResponse): + assert len(result.results) > 0 + + for item in result.results: + assert item.sheet == "Action" + assert isinstance(item.fields, dict) + + if "Name" in item.fields: + assert isinstance(item.fields["Name"], str) + assert len(item.fields["Name"]) > 0 + if "ID" in item.fields: + assert isinstance(item.fields["ID"], int) + assert len(item.fields["ID"]) > 0 + +@pytest.fixture +def client(): + return XIVAPI() + +# Version endpoint testing +def test_versions(client: XIVAPI): + versions = client.versions() + assert isinstance(versions, list) + assert len(versions) > 0 + for v in versions: + assert isinstance(v, str) + assert len(v) > 0 + +# Asset endpoint testing +def test_asset_get(client: XIVAPI): + assets = client.assets() + result = assets.get({ "path": "ui/icon/051000/051474_hr1.tex", "format": "png" }) + assert isinstance(result, (bytes, bytearray)) + assert len(result) > 0 + +def test_asset_invalid_path(client: XIVAPI): + assets = client.assets() + with pytest.raises(CustomError): + assets.get({ "path": "invalid/path/does/not/exist.tex", "format": "png" }) + + +def test_asset_map_invalid(client: XIVAPI): + assets = client.assets() + with pytest.raises(CustomError): + assets.map({ "territory": "invalid", "index": "00", "version": "latest", "format": "png" }) + +# Search endpoint testing +def test_search_exact_name(client: XIVAPI): + result = client.search({ "query": 'Name="Iron War Axe"', "sheets": "Item", "limit": 5 }) + validate_search(result) + validate_item(result) + found = next((i for i in result.results if i.fields.get("Name") == "Iron War Axe"), None) + assert found is not None + +def test_search_partial_name(client: XIVAPI): + result = client.search({ "query": 'Name~"sword"', "sheets": "Item", "limit": 5 }) + validate_search(result) + validate_item(result) + for item in result.results: + if "Name" in item.fields: + assert "sword" in item.fields["Name"].lower() + +def test_search_numeric(client: XIVAPI): + result = client.search({ "query": 'Recast100ms>3000', "sheets": "Action", "limit": 5 }) + validate_search(result) + validate_action(result) + for item in result.results: + if "Recast100ms" in item.fields: + assert item.fields["Recast100ms"] > 3000 + +def test_search_invalid_syntax(client: XIVAPI): + with pytest.raises(CustomError): + client.search({ "query": "invalid query syntax that should fail", "sheets": "Item", }) + +# Sheet(s) endpoint testing +def test_list_sheets(client: XIVAPI): + sheets = client.sheets() + result = sheets.all() + assert result.sheets + assert len(result.sheets) > 0 + for s in result.sheets: + assert isinstance(s.name, str) + +def test_list_sheet_rows(client: XIVAPI): + sheets = client.sheets() + result = sheets.list("Item", { "limit": 5 }) + assert result.rows + assert len(result.rows) > 0 + for r in result.rows: + assert isinstance(r.row_id, int) + assert isinstance(r.fields, dict) + +def test_get_row_with_fields(client: XIVAPI): + sheets = client.sheets() + result = sheets.get("Item", "1", { "fields": "Name", "language": "en" }) + assert result.row_id == 1 + assert result.fields.get("Name") == "Gil" + +def test_get_row_with_field_list(client: XIVAPI): + sheets = client.sheets() + result = sheets.get("Item", "1", { "fields": ["Name", "LevelItem"], "language": "en" }) + assert result.row_id == 1 + assert "Name" in result.fields + assert "LevelItem" in result.fields + +def test_list_nonexistent_sheet(client: XIVAPI): + sheets = client.sheets() + with pytest.raises(CustomError): sheets.list("NonExistentSheetThatDoesNotExist") + +# Custom options testing +def test_custom_options(): + client = XIVAPI(language="ja",verbose=True,version="latest") + result = client.items.get(1, { "fields": "Name" }) + assert result.row_id == 1 + assert result.fields.get("Name") == "ギル" \ No newline at end of file