From 1bf955876493a4af95866611278df3b5a2f61f49 Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 19:53:23 +0000 Subject: [PATCH 01/20] feat(XIVAPIv2): initial commit --- .github/workflows/ci-pytest.yml | 34 +++ .github/workflows/publish-pypi.yml | 24 ++ .github/workflows/publish.yml | 26 -- README.md | 228 ++++++---------- pyproject.toml | 83 ++++++ pyxivapi/__init__.py | 21 -- pyxivapi/client.py | 425 ----------------------------- pyxivapi/decorators.py | 16 -- pyxivapi/exceptions.py | 82 ------ pyxivapi/models.py | 29 -- requirements.txt | 2 - setup.py | 40 --- src/pyxivapi/__init__.py | 4 + src/pyxivapi/client.py | 40 +++ src/pyxivapi/py.typed | 0 src/pyxivapi/utils.py | 60 ++++ tests/test_pyxivapi.py | 150 ++++++++++ 17 files changed, 483 insertions(+), 781 deletions(-) create mode 100644 .github/workflows/ci-pytest.yml create mode 100644 .github/workflows/publish-pypi.yml delete mode 100644 .github/workflows/publish.yml create mode 100644 pyproject.toml delete mode 100644 pyxivapi/__init__.py delete mode 100644 pyxivapi/client.py delete mode 100644 pyxivapi/decorators.py delete mode 100644 pyxivapi/exceptions.py delete mode 100644 pyxivapi/models.py delete mode 100644 requirements.txt delete mode 100644 setup.py create mode 100644 src/pyxivapi/__init__.py create mode 100644 src/pyxivapi/client.py create mode 100644 src/pyxivapi/py.typed create mode 100644 src/pyxivapi/utils.py create mode 100644 tests/test_pyxivapi.py 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/README.md b/README.md index 131f8c1..ccb2d9e 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,98 @@ # 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 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..6ba25f0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pyxivapi" +version = "0.1.0" +description = "A Python client for the XIVAPI v2 API." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + { 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..622fb91 --- /dev/null +++ b/src/pyxivapi/__init__.py @@ -0,0 +1,4 @@ +from .client import XIVAPIClient +from .utils import CustomError + +__all__ = ["XIVAPIClient", "CustomError"] diff --git a/src/pyxivapi/client.py b/src/pyxivapi/client.py new file mode 100644 index 0000000..7276c32 --- /dev/null +++ b/src/pyxivapi/client.py @@ -0,0 +1,40 @@ +from typing import Any, Dict, Optional +from .lib.models import (SearchQuery, VersionQuery, RowReaderQuery, SearchResponse) +from .lib.sheets import Sheet, Sheets +from .lib.assets import Assets +from .lib.versions import Versions +from .utils import request, CustomError + +class XIVAPIClient: + """Python wrapper for the XIVAPI v2 API.""" + def __init__(self, options: Optional[Dict[str, Any]] = None) -> None: + # Updates the default options, if provided + self.options = { "version": "latest", "language": "en", "verbose": False } + if options: + self.options.update(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 (lazy) + self.data = { + "sheets": lambda: Sheets(self.options), + "versions": lambda: [v.names[0] for v in Versions().all().versions], + "assets": lambda: Assets() + } + + 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/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..d0ec267 --- /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]]: + 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..740ff9e --- /dev/null +++ b/tests/test_pyxivapi.py @@ -0,0 +1,150 @@ +import pytest +from pyxivapi import XIVAPIClient, 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 XIVAPIClient() + +# Version endpoint testing +def test_versions(client: XIVAPIClient): + versions = client.data["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: XIVAPIClient): + assets = client.data["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: XIVAPIClient): + assets = client.data["assets"]() + with pytest.raises(CustomError): + assets.get({ "path": "invalid/path/does/not/exist.tex", "format": "png" }) + + +def test_asset_map_invalid(client: XIVAPIClient): + assets = client.data["assets"]() + with pytest.raises(CustomError): + assets.map({ "territory": "invalid", "index": "00", "version": "latest", "format": "png" }) + +# Search endpoint testing +def test_search_exact_name(client: XIVAPIClient): + 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: XIVAPIClient): + 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: XIVAPIClient): + 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: XIVAPIClient): + with pytest.raises(CustomError): + client.search({ "query": "invalid query syntax that should fail", "sheets": "Item", }) + +# Sheet(s) endpoint testing +def test_list_sheets(client: XIVAPIClient): + sheets = client.data["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: XIVAPIClient): + sheets = client.data["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: XIVAPIClient): + sheets = client.data["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: XIVAPIClient): + sheets = client.data["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: XIVAPIClient): + sheets = client.data["sheets"]() + with pytest.raises(CustomError): sheets.list("NonExistentSheetThatDoesNotExist") + +# Custom options testing +def test_custom_options(): + client = XIVAPIClient({ "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 From 891bea2fee7afb9fa988439e583284de97761b8c Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 20:03:37 +0000 Subject: [PATCH 02/20] fix: revert to original description --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ba25f0..4519345 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyxivapi" -version = "0.1.0" -description = "A Python client for the XIVAPI v2 API." +description="An asynchronous Python client for XIVAPI", readme = "README.md" requires-python = ">=3.10" license = "MIT" From 752a022ce805c8dcc11a8864ae88cf313a7a15d8 Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 20:03:59 +0000 Subject: [PATCH 03/20] chore: add back original author --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4519345..f58439b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ 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"] From 1383d63130e3445547925b5d701a42d35b3d6f41 Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 20:04:17 +0000 Subject: [PATCH 04/20] chore: bump version to v0.6.0 --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f58439b..8191ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ 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" From 0634bc4e136b29c5b6a38452b26e33fb3adef08c Mon Sep 17 00:00:00 2001 From: Cammy <52957759+miichom@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:11:30 +0000 Subject: [PATCH 05/20] chore: removed artifact Removed unnecessary code block formatting from README, was used for an additional section --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index ccb2d9e..0a07481 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,3 @@ If you want to discuss an idea before implementing it, feel free to open an issu ## License `pyxivapi` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. - -``` - -``` From 1e950b3b9fcf577c46fcc1b205625f1eda56f3d9 Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 22:32:06 +0000 Subject: [PATCH 06/20] chore: remove lib declare --- .gitignore | 2 - src/pyxivapi/lib/assets.py | 36 +++++ src/pyxivapi/lib/models.py | 256 +++++++++++++++++++++++++++++++++++ src/pyxivapi/lib/sheets.py | 74 ++++++++++ src/pyxivapi/lib/versions.py | 10 ++ 5 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/pyxivapi/lib/assets.py create mode 100644 src/pyxivapi/lib/models.py create mode 100644 src/pyxivapi/lib/sheets.py create mode 100644 src/pyxivapi/lib/versions.py 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/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..286d812 --- /dev/null +++ b/src/pyxivapi/lib/models.py @@ -0,0 +1,256 @@ +from pydantic import BaseModel +from typing import Dict, List, Optional, Union +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 + """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 + 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] = 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 + 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. + Regex pattern: `^\d+(:\d+)?(,\d+(:\d+)?)*$` + """ + +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 + row_id: int + """ID of this row.""" + subrow_id: Optional[int] = None + """Subrow ID of this row, when relevant.""" + transient: Optional[dict] = 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 + """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 + row_id: int + """ID of this row.""" + schema: SchemaSpecifier + """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] = 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.""" \ 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..4baa7cc --- /dev/null +++ b/src/pyxivapi/lib/sheets.py @@ -0,0 +1,74 @@ +from typing import Any, Dict, Optional +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: Optional[Dict[str, Any]] = None) -> None: + self.type = sheet + self.options = options or {} + + 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: Optional[Dict[str, Any]] = None) -> None: + self.options = options or { "language": "en", "verbose": False } + + def all(self) -> ListResponse: + """List all known sheets.""" + data, errors = request(path="/sheet", params={}, options=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) From 201ea9bccb85e79b72a9db8fd871092add90cc1d Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 22:37:21 +0000 Subject: [PATCH 07/20] fix: missing and invalid comma --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8191ad2..758e10f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,12 +5,12 @@ build-backend = "hatchling.build" [project] name = "pyxivapi" version = "0.6.0" -description="An asynchronous Python client for XIVAPI", +description="An asynchronous Python client for XIVAPI" readme = "README.md" requires-python = ">=3.10" license = "MIT" authors = [ - { name = "Lethys", email = "seraymericbot@gmail.com" } + { name = "Lethys", email = "seraymericbot@gmail.com" }, { name = "miichom", email = "hello@cammy.xyz" } ] keywords = ["xivapi", "final fantasy xiv", "ffxiv", "api client"] From c3b9d4cf737e7fc4ce8bd7779a75d05f4f9086ff Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 19 Feb 2026 23:15:13 +0000 Subject: [PATCH 08/20] chore: improve typings --- src/pyxivapi/client.py | 31 +++++++++++++------------------ src/pyxivapi/lib/models.py | 25 ++++++++++++++++++++----- src/pyxivapi/lib/sheets.py | 13 +++++++------ tests/test_pyxivapi.py | 20 ++++++++++---------- 4 files changed, 50 insertions(+), 39 deletions(-) diff --git a/src/pyxivapi/client.py b/src/pyxivapi/client.py index 7276c32..62c469b 100644 --- a/src/pyxivapi/client.py +++ b/src/pyxivapi/client.py @@ -1,30 +1,25 @@ -from typing import Any, Dict, Optional -from .lib.models import (SearchQuery, VersionQuery, RowReaderQuery, SearchResponse) +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 XIVAPIClient: """Python wrapper for the XIVAPI v2 API.""" - def __init__(self, options: Optional[Dict[str, Any]] = None) -> None: - # Updates the default options, if provided - self.options = { "version": "latest", "language": "en", "verbose": False } - if options: - self.options.update(options) + 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) + 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 (lazy) - self.data = { - "sheets": lambda: Sheets(self.options), - "versions": lambda: [v.names[0] for v in Versions().all().versions], - "assets": lambda: Assets() - } + # 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: """ diff --git a/src/pyxivapi/lib/models.py b/src/pyxivapi/lib/models.py index 286d812..2801961 100644 --- a/src/pyxivapi/lib/models.py +++ b/src/pyxivapi/lib/models.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional, Union, Any, TypedDict, NotRequired from enum import Enum class VersionQuery(BaseModel): @@ -114,7 +114,7 @@ class SearchResult(BaseModel): See: https://v2.xivapi.com/api/docs#model/searchresult """ - fields: dict + fields: dict[str, Any] row_id: int """ID of this row.""" score: float @@ -170,7 +170,6 @@ class SheetQuery(BaseModel): 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. - Regex pattern: `^\d+(:\d+)?(,\d+(:\d+)?)*$` """ class SheetPath(BaseModel): @@ -188,7 +187,7 @@ class RowResult(BaseModel): See: https://v2.xivapi.com/api/docs#model/rowresult """ - fields: dict + fields: dict[str, Any] row_id: int """ID of this row.""" subrow_id: Optional[int] = None @@ -253,4 +252,20 @@ class VersionsResponse(BaseModel): See: https://v2.xivapi.com/api/docs#model/versionsresponse """ versions: List[VersionMetadata] - """List of versions available in the API.""" \ No newline at end of file + """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 index 4baa7cc..913b595 100644 --- a/src/pyxivapi/lib/sheets.py +++ b/src/pyxivapi/lib/sheets.py @@ -1,4 +1,5 @@ -from typing import Any, Dict, Optional +from typing import Optional, Unpack +from pyxivapi.client import XIVAPIOptions from .models import (RowReaderQuery, SheetQuery, RowResponse, SheetResponse, ListResponse, SchemaSpecifier) from ..utils import request, CustomError @@ -8,9 +9,9 @@ class Sheet: See: https://v2.xivapi.com/api/docs#tag/sheets """ - def __init__(self, sheet: SchemaSpecifier, options: Optional[Dict[str, Any]] = None) -> None: + def __init__(self, sheet: SchemaSpecifier, **options: Unpack[XIVAPIOptions]) -> None: self.type = sheet - self.options = options or {} + self.options = XIVAPIOptions(**options) def get(self, row_id: str | int, params: Optional[RowReaderQuery] = None) -> RowResponse: """ @@ -20,7 +21,7 @@ def get(self, row_id: str | int, params: Optional[RowReaderQuery] = None) -> Row """ try: row_id = str(row_id) - return Sheets(self.options).get(self.type, row_id, params or RowReaderQuery()) + return Sheets(**self.options).get(self.type, row_id, params or RowReaderQuery()) except Exception as e: raise CustomError(str(e)) @@ -41,8 +42,8 @@ class Sheets: See: https://v2.xivapi.com/api/docs#tag/sheets """ - def __init__(self, options: Optional[Dict[str, Any]] = None) -> None: - self.options = options or { "language": "en", "verbose": False } + def __init__(self, **options: Unpack[XIVAPIOptions]) -> None: + self.options = XIVAPIOptions(**options) def all(self) -> ListResponse: """List all known sheets.""" diff --git a/tests/test_pyxivapi.py b/tests/test_pyxivapi.py index 740ff9e..f9e52fa 100644 --- a/tests/test_pyxivapi.py +++ b/tests/test_pyxivapi.py @@ -54,7 +54,7 @@ def client(): # Version endpoint testing def test_versions(client: XIVAPIClient): - versions = client.data["versions"]() + versions = client.versions() assert isinstance(versions, list) assert len(versions) > 0 for v in versions: @@ -63,19 +63,19 @@ def test_versions(client: XIVAPIClient): # Asset endpoint testing def test_asset_get(client: XIVAPIClient): - assets = client.data["assets"]() + 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: XIVAPIClient): - assets = client.data["assets"]() + assets = client.assets() with pytest.raises(CustomError): assets.get({ "path": "invalid/path/does/not/exist.tex", "format": "png" }) def test_asset_map_invalid(client: XIVAPIClient): - assets = client.data["assets"]() + assets = client.assets() with pytest.raises(CustomError): assets.map({ "territory": "invalid", "index": "00", "version": "latest", "format": "png" }) @@ -109,7 +109,7 @@ def test_search_invalid_syntax(client: XIVAPIClient): # Sheet(s) endpoint testing def test_list_sheets(client: XIVAPIClient): - sheets = client.data["sheets"]() + sheets = client.sheets() result = sheets.all() assert result.sheets assert len(result.sheets) > 0 @@ -117,7 +117,7 @@ def test_list_sheets(client: XIVAPIClient): assert isinstance(s.name, str) def test_list_sheet_rows(client: XIVAPIClient): - sheets = client.data["sheets"]() + sheets = client.sheets() result = sheets.list("Item", { "limit": 5 }) assert result.rows assert len(result.rows) > 0 @@ -126,25 +126,25 @@ def test_list_sheet_rows(client: XIVAPIClient): assert isinstance(r.fields, dict) def test_get_row_with_fields(client: XIVAPIClient): - sheets = client.data["sheets"]() + 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: XIVAPIClient): - sheets = client.data["sheets"]() + 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: XIVAPIClient): - sheets = client.data["sheets"]() + sheets = client.sheets() with pytest.raises(CustomError): sheets.list("NonExistentSheetThatDoesNotExist") # Custom options testing def test_custom_options(): - client = XIVAPIClient({ "language": "ja", "verbose": True, "version": "latest" }) + client = XIVAPIClient(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 From 52071c854c440659e1a4b9d7ac2f88849cf8cdcd Mon Sep 17 00:00:00 2001 From: miichom Date: Fri, 20 Feb 2026 08:53:17 +0000 Subject: [PATCH 09/20] chore: refactor typings --- src/pyxivapi/__init__.py | 4 ++-- src/pyxivapi/client.py | 2 +- src/pyxivapi/lib/models.py | 16 ++++++++-------- src/pyxivapi/lib/sheets.py | 4 ++-- src/pyxivapi/utils.py | 2 +- tests/test_pyxivapi.py | 32 ++++++++++++++++---------------- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/pyxivapi/__init__.py b/src/pyxivapi/__init__.py index 622fb91..090b140 100644 --- a/src/pyxivapi/__init__.py +++ b/src/pyxivapi/__init__.py @@ -1,4 +1,4 @@ -from .client import XIVAPIClient +from .client import XIVAPI from .utils import CustomError -__all__ = ["XIVAPIClient", "CustomError"] +__all__ = ["XIVAPI", "CustomError"] diff --git a/src/pyxivapi/client.py b/src/pyxivapi/client.py index 62c469b..18e8dbd 100644 --- a/src/pyxivapi/client.py +++ b/src/pyxivapi/client.py @@ -5,7 +5,7 @@ from .lib.versions import Versions from .utils import request, CustomError -class XIVAPIClient: +class XIVAPI: """Python wrapper for the XIVAPI v2 API.""" def __init__(self, **options: Unpack[XIVAPIOptions]) -> None: self.options = XIVAPIOptions(**options) diff --git a/src/pyxivapi/lib/models.py b/src/pyxivapi/lib/models.py index 2801961..a80e6b7 100644 --- a/src/pyxivapi/lib/models.py +++ b/src/pyxivapi/lib/models.py @@ -103,7 +103,7 @@ class RowReaderQuery(BaseModel): """Comma-separated list of field paths to select.""" language: Optional[Union[str, SchemaLanguage]] = None """Language to read row data with.""" - schema: Optional[SchemaSpecifier] = None + schema: Optional[SchemaSpecifier] = None # pyright: ignore[reportIncompatibleMethodOverride] """Schema specifier for row data.""" transient: Optional[FilterString] = None """Transient row field selection.""" @@ -126,7 +126,7 @@ class SearchResult(BaseModel): """Excel sheet this result was found in.""" subrow_id: Optional[int] = None """Subrow ID of this row, when relevant.""" - transient: Optional[dict] = None + 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): @@ -136,7 +136,7 @@ class SearchResponse(BaseModel): See: https://v2.xivapi.com/api/docs#model/searchresponse """ results: List[SearchResult] - schema: SchemaSpecifier + schema: SchemaSpecifier # pyright: ignore[reportIncompatibleMethodOverride] next: Optional[str] = None class SheetMetadata(BaseModel): @@ -192,7 +192,7 @@ class RowResult(BaseModel): """ID of this row.""" subrow_id: Optional[int] = None """Subrow ID of this row, when relevant.""" - transient: Optional[dict] = None + 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): @@ -207,7 +207,7 @@ class SheetResponse(BaseModel): See: https://v2.xivapi.com/api/docs#model/rowresult """ - schema: SchemaSpecifier + schema: SchemaSpecifier # type: ignore - schema exists on BaseModel """The canonical specifier for the schema used in this response.""" class RowPath(BaseModel): @@ -226,14 +226,14 @@ class RowResponse(BaseModel): See: https://v2.xivapi.com/api/docs#model/rowresponse """ - fields: dict + fields: dict[str, Any] row_id: int """ID of this row.""" - schema: SchemaSpecifier + 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] = None + 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): diff --git a/src/pyxivapi/lib/sheets.py b/src/pyxivapi/lib/sheets.py index 913b595..00f5752 100644 --- a/src/pyxivapi/lib/sheets.py +++ b/src/pyxivapi/lib/sheets.py @@ -32,7 +32,7 @@ def list(self, params: Optional[SheetQuery] = None) -> SheetResponse: See: https://v2.xivapi.com/api/docs#tag/sheets/get/sheet/{sheet} """ try: - return Sheets(self.options).list(self.type, params or SheetQuery()) + return Sheets(**self.options).list(self.type, params or SheetQuery()) except Exception as e: raise CustomError(str(e)) @@ -47,7 +47,7 @@ def __init__(self, **options: Unpack[XIVAPIOptions]) -> None: def all(self) -> ListResponse: """List all known sheets.""" - data, errors = request(path="/sheet", params={}, options=self.options) + data, errors = request(path="/sheet", params={}, **self.options) if errors: raise CustomError(errors[0]["message"]) return ListResponse(**data) diff --git a/src/pyxivapi/utils.py b/src/pyxivapi/utils.py index d0ec267..a22cc26 100644 --- a/src/pyxivapi/utils.py +++ b/src/pyxivapi/utils.py @@ -11,7 +11,7 @@ def __init__(self, message: str, name: Optional[str] = None): 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]]: +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 {} diff --git a/tests/test_pyxivapi.py b/tests/test_pyxivapi.py index f9e52fa..38ce919 100644 --- a/tests/test_pyxivapi.py +++ b/tests/test_pyxivapi.py @@ -1,5 +1,5 @@ import pytest -from pyxivapi import XIVAPIClient, CustomError +from pyxivapi import XIVAPI, CustomError from pyxivapi.lib.models import SearchResponse API_TIMEOUT = 10 # seconds @@ -50,10 +50,10 @@ def validate_action(result: SearchResponse): @pytest.fixture def client(): - return XIVAPIClient() + return XIVAPI() # Version endpoint testing -def test_versions(client: XIVAPIClient): +def test_versions(client: XIVAPI): versions = client.versions() assert isinstance(versions, list) assert len(versions) > 0 @@ -62,32 +62,32 @@ def test_versions(client: XIVAPIClient): assert len(v) > 0 # Asset endpoint testing -def test_asset_get(client: XIVAPIClient): +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: XIVAPIClient): +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: XIVAPIClient): +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: XIVAPIClient): +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: XIVAPIClient): +def test_search_partial_name(client: XIVAPI): result = client.search({ "query": 'Name~"sword"', "sheets": "Item", "limit": 5 }) validate_search(result) validate_item(result) @@ -95,7 +95,7 @@ def test_search_partial_name(client: XIVAPIClient): if "Name" in item.fields: assert "sword" in item.fields["Name"].lower() -def test_search_numeric(client: XIVAPIClient): +def test_search_numeric(client: XIVAPI): result = client.search({ "query": 'Recast100ms>3000', "sheets": "Action", "limit": 5 }) validate_search(result) validate_action(result) @@ -103,12 +103,12 @@ def test_search_numeric(client: XIVAPIClient): if "Recast100ms" in item.fields: assert item.fields["Recast100ms"] > 3000 -def test_search_invalid_syntax(client: XIVAPIClient): +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: XIVAPIClient): +def test_list_sheets(client: XIVAPI): sheets = client.sheets() result = sheets.all() assert result.sheets @@ -116,7 +116,7 @@ def test_list_sheets(client: XIVAPIClient): for s in result.sheets: assert isinstance(s.name, str) -def test_list_sheet_rows(client: XIVAPIClient): +def test_list_sheet_rows(client: XIVAPI): sheets = client.sheets() result = sheets.list("Item", { "limit": 5 }) assert result.rows @@ -125,26 +125,26 @@ def test_list_sheet_rows(client: XIVAPIClient): assert isinstance(r.row_id, int) assert isinstance(r.fields, dict) -def test_get_row_with_fields(client: XIVAPIClient): +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: XIVAPIClient): +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: XIVAPIClient): +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 = XIVAPIClient(language="ja",verbose=True,version="latest") + 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 From 57fdbc3ab3240d4ab05d6be2373b670cdce08b91 Mon Sep 17 00:00:00 2001 From: miichom Date: Fri, 20 Feb 2026 08:53:44 +0000 Subject: [PATCH 10/20] docs: use correct options params --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0a07481..7fcf74e 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ from pyxivapi import XIVAPI # Basic instance xiv = XIVAPI() -# With options +# With custom options xiv_custom = XIVAPI( - version="7.0", # specify game version - language="ja", # ja, en, de, fr - verbose=True # enable debug logging + version="7.0" # specify game version + language="ja" # ja, en, de, fr + verbose=True # enable debug logging ) ``` From 6cde6462e3c3d0cf4014e330311281ec282cbcea Mon Sep 17 00:00:00 2001 From: miichom Date: Thu, 5 Mar 2026 12:24:45 +0000 Subject: [PATCH 11/20] docs: update repository URLs in README and pyproject.toml --- README.md | 4 ++-- pyproject.toml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7fcf74e..16a5aaa 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ Contributions of any kind are welcome - bug fixes, improvements, new features, o ### Getting Started ```bash -git clone https://github.com/miichom/pyxivapi.git -cd pyxivapi +git clone https://github.com/xivapi/xivapi-py.git +cd xivapi-py hatch env create dev ``` diff --git a/pyproject.toml b/pyproject.toml index 758e10f..7e1efaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,9 @@ dependencies = [ ] [project.urls] -Documentation = "https://github.com/miichom/pyxivapi#readme" -Issues = "https://github.com/miichom/pyxivapi/issues" -Source = "https://github.com/miichom/pyxivapi" +Documentation = "https://github.com/xivapi/xivapi-py#readme" +Issues = "https://github.com/xivapi/xivapi-py/issues" +Source = "https://github.com/xivapi/xivapi-py" [tool.hatch.envs.dev] dependencies = [ From 5f856d4af8c540bc25130b6f24673fbcdf1086b6 Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 10:59:48 +0100 Subject: [PATCH 12/20] fix: update branch name from 'main' to 'master' in CI configuration --- .github/workflows/ci-pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml index f2e99da..ebbd754 100644 --- a/.github/workflows/ci-pytest.yml +++ b/.github/workflows/ci-pytest.yml @@ -1,9 +1,9 @@ name: CI Pytest on: push: - branches: ["main"] + branches: ["master"] pull_request: - branches: ["main"] + branches: ["master"] jobs: build: runs-on: "ubuntu-latest" From 997fefb9c058fc619a7caddb00ba22d9b0985a8a Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 10:59:53 +0100 Subject: [PATCH 13/20] docs: add CI badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 16a5aaa..315682b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![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) +[![CI Pytest](https://github.com/miichom/xivapi-py/actions/workflows/ci-pytest.yml/badge.svg)](https://github.com/miichom/xivapi-py/actions/workflows/ci-pytest.yml) 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. From 3c7b13dc69e22d456a025c48633e9f9259645f57 Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 11:54:47 +0100 Subject: [PATCH 14/20] feat: add support for Python 3.13 and 3.14, update dependencies, and improve type hints --- .github/workflows/ci-pytest.yml | 4 ++-- pyproject.toml | 5 +++-- src/pyxivapi/client.py | 8 ++++---- src/pyxivapi/lib/assets.py | 10 +++++----- src/pyxivapi/lib/models.py | 5 ++--- src/pyxivapi/lib/sheets.py | 29 +++++++++++++---------------- src/pyxivapi/utils.py | 4 ++-- 7 files changed, 31 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml index ebbd754..785e62d 100644 --- a/.github/workflows/ci-pytest.yml +++ b/.github/workflows/ci-pytest.yml @@ -9,7 +9,7 @@ jobs: runs-on: "ubuntu-latest" strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v5 - name: Setup Python ${{ matrix.python-version }} @@ -20,7 +20,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . - pip install pytest pytest-cov mypy ruff + pip install types-requests pytest pytest-cov mypy ruff - name: Lint with Ruff run: ruff check src/pyxivapi - name: Type check with mypy diff --git a/pyproject.toml b/pyproject.toml index 7e1efaf..7691874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,8 @@ classifiers = [ ] dependencies = [ "requests>=2.31.0", - "pydantic>=2.6.0" + "pydantic>=2.6.0", + "typing_extensions>=4.0.0; python_version < '3.11'" ] [project.urls] @@ -42,7 +43,7 @@ dependencies = [ "pytest-cov>=4.0", "mypy>=1.0.0", "ruff>=0.3.0", - "types-requests" + "types-requests", ] [tool.hatch.envs.dev.scripts] diff --git a/src/pyxivapi/client.py b/src/pyxivapi/client.py index 18e8dbd..88ddaa2 100644 --- a/src/pyxivapi/client.py +++ b/src/pyxivapi/client.py @@ -1,10 +1,10 @@ -from typing import Any, Dict, Unpack +from typing_extensions 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: @@ -29,7 +29,7 @@ def search(self, params: Dict[str, Any] | SearchQuery | VersionQuery | RowReader """ if isinstance(params, dict): params = SearchQuery(**params) - data, errors = request(path="/search", params=params.model_dump(exclude_none=True), options=self.options) + data, errors = request(path="/search", params=params.model_dump(exclude_none=True), options=dict(self.options)) # pyright: ignore[reportArgumentType] if errors: raise CustomError(errors[0]["message"]) - return SearchResponse(**data) + return SearchResponse(**data) \ No newline at end of file diff --git a/src/pyxivapi/lib/assets.py b/src/pyxivapi/lib/assets.py index 7c4c8cb..a7d5742 100644 --- a/src/pyxivapi/lib/assets.py +++ b/src/pyxivapi/lib/assets.py @@ -1,4 +1,4 @@ -from typing import Dict, Any +from typing_extensions import Dict, Any, cast from .models import AssetQuery, MapPath, VersionQuery from ..utils import request, CustomError @@ -15,11 +15,11 @@ def get(self, params: AssetQuery) -> bytes: See: https://v2.xivapi.com/api/docs#tag/assets/get/asset """ if isinstance(params, dict): - params = AssetQuery(**params) + params = AssetQuery(**params) # pyright: ignore[reportUnknownArgumentType] data, errors = request(path="/asset", params=params.model_dump(exclude_none=True)) if errors: raise CustomError(errors[0]["message"]) - return data.get("data", data) + return cast(bytes, data.get("data", data)) def map(self, params: MapPath | VersionQuery | Dict[str, Any]) -> bytes: """ @@ -30,7 +30,7 @@ def map(self, params: MapPath | VersionQuery | Dict[str, Any]) -> bytes: if isinstance(params, dict): # MapPath + VersionQuery + {"format": ...} params = {k: v for k, v in params.items()} - data, errors = request(path="/asset",params=params) + data, errors = request(path="/asset", params=dict(params)) # pyright: ignore[reportArgumentType] if errors: raise CustomError(errors[0]["message"]) - return data.get("data", data) \ No newline at end of file + return cast(bytes, data.get("data", data)) \ No newline at end of file diff --git a/src/pyxivapi/lib/models.py b/src/pyxivapi/lib/models.py index a80e6b7..672959e 100644 --- a/src/pyxivapi/lib/models.py +++ b/src/pyxivapi/lib/models.py @@ -1,7 +1,6 @@ +from typing_extensions import Dict, List, Optional, Union, Any, TypedDict, NotRequired 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. @@ -207,7 +206,7 @@ class SheetResponse(BaseModel): See: https://v2.xivapi.com/api/docs#model/rowresult """ - schema: SchemaSpecifier # type: ignore - schema exists on BaseModel + schema: SchemaSpecifier # pyright: ignore[reportIncompatibleMethodOverride] """The canonical specifier for the schema used in this response.""" class RowPath(BaseModel): diff --git a/src/pyxivapi/lib/sheets.py b/src/pyxivapi/lib/sheets.py index 00f5752..e10664f 100644 --- a/src/pyxivapi/lib/sheets.py +++ b/src/pyxivapi/lib/sheets.py @@ -1,6 +1,5 @@ -from typing import Optional, Unpack -from pyxivapi.client import XIVAPIOptions -from .models import (RowReaderQuery, SheetQuery, RowResponse, SheetResponse, ListResponse, SchemaSpecifier) +from typing_extensions import Optional, Unpack +from .models import (RowReaderQuery, SheetQuery, RowResponse, SheetResponse, ListResponse, SchemaSpecifier, XIVAPIOptions) from ..utils import request, CustomError class Sheet: @@ -47,29 +46,27 @@ def __init__(self, **options: Unpack[XIVAPIOptions]) -> None: def all(self) -> ListResponse: """List all known sheets.""" - data, errors = request(path="/sheet", params={}, **self.options) + data, errors = request(path="/sheet", params={}, options=dict(self.options)) # pyright: ignore[reportUnknownVariableType, reportCallIssue] if errors: - raise CustomError(errors[0]["message"]) - return ListResponse(**data) + raise CustomError(errors[0]["message"]) # pyright: ignore[reportUnknownArgumentType] + return ListResponse(**data) # pyright: ignore[reportUnknownArgumentType] 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) + params = SheetQuery() if params is None else params + if isinstance(params, dict): + params = SheetQuery(**params) # pyright: ignore[reportUnknownArgumentType] + data, errors = request(path=f"/sheet/{sheet}", params=params.model_dump(exclude_none=True), options=dict(self.options)) # pyright: ignore[reportArgumentType] 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) + params = RowReaderQuery() if params is None else params + if isinstance(params, dict): + params = RowReaderQuery(**params) # pyright: ignore[reportUnknownArgumentType] + data, errors = request(path=f"/sheet/{sheet}/{row}", params=params.model_dump(exclude_none=True), options=dict(self.options)) # pyright: ignore[reportArgumentType, reportOptionalMemberAccess] if errors: raise CustomError(errors[0]["message"]) return RowResponse(**data) \ No newline at end of file diff --git a/src/pyxivapi/utils.py b/src/pyxivapi/utils.py index a22cc26..4bad8e5 100644 --- a/src/pyxivapi/utils.py +++ b/src/pyxivapi/utils.py @@ -1,6 +1,6 @@ import requests from urllib.parse import urlencode, urljoin -from typing import Any, Dict, Optional, Tuple +from typing_extensions import Any, Dict, Optional, Tuple # The endpoint to use, kept at the top for quick changing (if needed) endpoint = "https://v2.xivapi.com/api/" @@ -55,6 +55,6 @@ def request(*, path: str, params: Optional[Dict[str, Any]] = None, options: Opti try: error_json = response.json() except Exception: - error_json = { "message": "Unknown error", "code": response.status_code } + error_json = { "message": "Unknown error", "code": response.status_code } # pyright: ignore[reportUnknownVariableType] return {}, [error_json] \ No newline at end of file From f9d150611577aa27579a81ed6aac2ccc62478666 Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 12:05:37 +0100 Subject: [PATCH 15/20] refactor: streamline CI configuration and enhance dependency management with Hatch --- .github/workflows/ci-pytest.yml | 15 +++++++-------- pyproject.toml | 3 +-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml index 785e62d..07a7a02 100644 --- a/.github/workflows/ci-pytest.yml +++ b/.github/workflows/ci-pytest.yml @@ -16,17 +16,16 @@ jobs: 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 types-requests pytest pytest-cov mypy ruff + - name: Install Hatch + run: pip install --upgrade hatch + - name: Install dependencies with Hatch + run: hatch env run --env-name dev pip install --upgrade pip - name: Lint with Ruff - run: ruff check src/pyxivapi + run: hatch run dev:lint - name: Type check with mypy - run: mypy src/pyxivapi + run: hatch run dev:types - name: Run tests - run: pytest -v --cov=pyxivapi --cov-report=xml + run: hatch run dev:test - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/pyproject.toml b/pyproject.toml index 7691874..ae97694 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ classifiers = [ dependencies = [ "requests>=2.31.0", "pydantic>=2.6.0", - "typing_extensions>=4.0.0; python_version < '3.11'" ] [project.urls] @@ -47,7 +46,7 @@ dependencies = [ ] [tool.hatch.envs.dev.scripts] -test = "pytest -v" +test = "pytest -v --cov=pyxivapi --cov-report=xml" lint = "ruff check src/pyxivapi" types = "mypy --install-types --non-interactive src/pyxivapi" From e46a4aac97d87731d8c74117467e3903a22e2c87 Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 12:05:44 +0100 Subject: [PATCH 16/20] docs: add installation instruction for Hatch in README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 315682b..a015e97 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Contributions of any kind are welcome - bug fixes, improvements, new features, o ```bash git clone https://github.com/xivapi/xivapi-py.git cd xivapi-py +pip install hatch # if you don't have it already hatch env create dev ``` From aca2d48dc2a314cee36831baf014633dbcada68d Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 12:07:13 +0100 Subject: [PATCH 17/20] fix: correct environment name in CI configuration for dependency installation --- .github/workflows/ci-pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml index 07a7a02..545b554 100644 --- a/.github/workflows/ci-pytest.yml +++ b/.github/workflows/ci-pytest.yml @@ -19,7 +19,7 @@ jobs: - name: Install Hatch run: pip install --upgrade hatch - name: Install dependencies with Hatch - run: hatch env run --env-name dev pip install --upgrade pip + run: hatch env run --env dev pip install --upgrade pip - name: Lint with Ruff run: hatch run dev:lint - name: Type check with mypy From 15c90b998eeac30ad2fc289f214af271dcd4a2a0 Mon Sep 17 00:00:00 2001 From: miichom Date: Sat, 2 May 2026 12:09:12 +0100 Subject: [PATCH 18/20] refactor: update CI configuration to create Hatch environment instead of installing dependencies directly --- .github/workflows/ci-pytest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-pytest.yml b/.github/workflows/ci-pytest.yml index 545b554..4448940 100644 --- a/.github/workflows/ci-pytest.yml +++ b/.github/workflows/ci-pytest.yml @@ -18,8 +18,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install Hatch run: pip install --upgrade hatch - - name: Install dependencies with Hatch - run: hatch env run --env dev pip install --upgrade pip + - name: Create Hatch environment + run: hatch env create dev - name: Lint with Ruff run: hatch run dev:lint - name: Type check with mypy From fb46780467f772af4a5c2a2bedbfea7a32dbee27 Mon Sep 17 00:00:00 2001 From: miichom Date: Wed, 6 May 2026 19:31:25 +0100 Subject: [PATCH 19/20] refactor: remove MapPath model and update asset mapping logic in Assets class --- src/pyxivapi/lib/assets.py | 6 +++--- src/pyxivapi/lib/models.py | 19 ------------------- tests/test_pyxivapi.py | 7 ++++++- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/pyxivapi/lib/assets.py b/src/pyxivapi/lib/assets.py index a7d5742..0423ec3 100644 --- a/src/pyxivapi/lib/assets.py +++ b/src/pyxivapi/lib/assets.py @@ -1,5 +1,5 @@ from typing_extensions import Dict, Any, cast -from .models import AssetQuery, MapPath, VersionQuery +from .models import AssetQuery, VersionQuery from ..utils import request, CustomError class Assets: @@ -21,7 +21,7 @@ def get(self, params: AssetQuery) -> bytes: raise CustomError(errors[0]["message"]) return cast(bytes, data.get("data", data)) - def map(self, params: MapPath | VersionQuery | Dict[str, Any]) -> bytes: + def map(self, territory: str, index: str, params: VersionQuery | Dict[str, Any]) -> bytes: """ Retrieve the specified map, composing it from split source files if necessary (`GET /asset/map`). @@ -30,7 +30,7 @@ def map(self, params: MapPath | VersionQuery | Dict[str, Any]) -> bytes: if isinstance(params, dict): # MapPath + VersionQuery + {"format": ...} params = {k: v for k, v in params.items()} - data, errors = request(path="/asset", params=dict(params)) # pyright: ignore[reportArgumentType] + data, errors = request(path=f"/asset/map/{territory}/{index}", params=dict(params)) # pyright: ignore[reportArgumentType] if errors: raise CustomError(errors[0]["message"]) return cast(bytes, data.get("data", data)) \ No newline at end of file diff --git a/src/pyxivapi/lib/models.py b/src/pyxivapi/lib/models.py index 672959e..60011d2 100644 --- a/src/pyxivapi/lib/models.py +++ b/src/pyxivapi/lib/models.py @@ -36,25 +36,6 @@ class ErrorResponse(BaseModel): 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] diff --git a/tests/test_pyxivapi.py b/tests/test_pyxivapi.py index 38ce919..d05dee3 100644 --- a/tests/test_pyxivapi.py +++ b/tests/test_pyxivapi.py @@ -73,11 +73,16 @@ def test_asset_invalid_path(client: XIVAPI): with pytest.raises(CustomError): assets.get({ "path": "invalid/path/does/not/exist.tex", "format": "png" }) +def test_asset_map(client: XIVAPI): + assets = client.assets() + result = assets.map("s1d1", "00", { "version": "latest" }) + assert isinstance(result, (bytes, bytearray)) + assert len(result) > 0 def test_asset_map_invalid(client: XIVAPI): assets = client.assets() with pytest.raises(CustomError): - assets.map({ "territory": "invalid", "index": "00", "version": "latest", "format": "png" }) + assets.map("invalid", "00", { "version": "latest" }) # Search endpoint testing def test_search_exact_name(client: XIVAPI): From 358ea10e07323b4574985e0b0f9c450eae276515 Mon Sep 17 00:00:00 2001 From: Cammy <52957759+miichom@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:06:40 +0100 Subject: [PATCH 20/20] docs: fix broken CI pytest badge url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a015e97..66292f5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![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) -[![CI Pytest](https://github.com/miichom/xivapi-py/actions/workflows/ci-pytest.yml/badge.svg)](https://github.com/miichom/xivapi-py/actions/workflows/ci-pytest.yml) +[![CI Pytest](https://github.com/xivapi/xivapi-py/actions/workflows/ci-pytest.yml/badge.svg)](https://github.com/xivapi/xivapi-py/actions/workflows/ci-pytest.yml) 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.