diff --git a/src/instawow/_sources/github.py b/src/instawow/_sources/github.py index fa14dd27..94c3ab57 100644 --- a/src/instawow/_sources/github.py +++ b/src/instawow/_sources/github.py @@ -15,11 +15,11 @@ from ..catalogue.cataloguer import AddonKey, CatalogueEntry from ..definitions import ChangelogFormat, Defn, SourceMetadata, Strategy from ..http import CACHE_INDEFINITELY, ClientSessionType +from ..matchers.addon_toc import TocReader from ..pkg_archives import find_archive_addon_tocs from ..resolvers import BaseResolver, HeadersIntent, PkgCandidate from ..utils import ( StrEnum, - TocReader, as_plain_text_data_url, extract_byte_range_offset, ) @@ -263,11 +263,15 @@ async def __find_match_from_zip_contents(self, assets: list[_GithubRelease_Asset toc_file_text = dynamic_addon_zip.read(main_toc_filename).decode('utf-8-sig') toc_reader = TocReader(toc_file_text) - interface_version = toc_reader['Interface'] - logger.debug(f'found interface version {interface_version!r} in {main_toc_filename}') - if interface_version and self._manager_ctx.config.game_flavour.to_flavour_keyed_enum( + logger.debug( + f'found interface versions {toc_reader.interfaces!r} in {main_toc_filename}' + ) + desired_version_range = self._manager_ctx.config.game_flavour.to_flavour_keyed_enum( FlavourVersionRange - ).contains(int(interface_version)): + ) + if toc_reader.interfaces and any( + desired_version_range.contains(i) for i in toc_reader.interfaces + ): matching_asset = candidate break @@ -293,10 +297,10 @@ async def __find_match_from_release_json( if not releases: return None - wanted_release_json_flavor = self._manager_ctx.config.game_flavour.to_flavour_keyed_enum( + desired_release_json_flavor = self._manager_ctx.config.game_flavour.to_flavour_keyed_enum( _PackagerReleaseJsonFlavor ) - wanted_version_range = self._manager_ctx.config.game_flavour.to_flavour_keyed_enum( + desired_version_range = self._manager_ctx.config.game_flavour.to_flavour_keyed_enum( FlavourVersionRange ) @@ -305,13 +309,13 @@ def is_compatible_release(release: _PackagerReleaseJson_Release): return False for metadata in release['metadata']: - if metadata['flavor'] != wanted_release_json_flavor: + if metadata['flavor'] != desired_release_json_flavor: continue interface_version = metadata['interface'] - if not wanted_version_range.contains(interface_version): + if not desired_version_range.contains(interface_version): logger.info( - f'interface number "{interface_version}" and flavor "{wanted_release_json_flavor}" mismatch' + f'interface number "{interface_version}" and flavor "{desired_release_json_flavor}" mismatch' ) continue diff --git a/src/instawow/matchers/__init__.py b/src/instawow/matchers/__init__.py index 6468abe5..ad1a352b 100644 --- a/src/instawow/matchers/__init__.py +++ b/src/instawow/matchers/__init__.py @@ -15,7 +15,6 @@ from ..catalogue import synchronise as synchronise_catalogue from ..definitions import Defn from ..utils import ( - TocReader, bucketise, fauxfrozen, gather, @@ -24,6 +23,7 @@ ) from ..wow_installations import Flavour from ._addon_hashing import generate_wowup_addon_hash +from .addon_toc import TocReader class Matcher(Protocol): # pragma: no cover @@ -81,11 +81,14 @@ def hash_contents(self, __method: AddonHashMethod) -> str: return generate_wowup_addon_hash(self.path) def get_defns_from_toc_keys(self, keys_and_ids: Iterable[tuple[str, str]]) -> frozenset[Defn]: - return frozenset(Defn(s, i) for k, s in keys_and_ids for i in (self.toc_reader[k],) if i) + return frozenset( + Defn(s, i) for k, s in keys_and_ids for i in (self.toc_reader.get(k),) if i + ) @cached_property def version(self) -> str: - return self.toc_reader['Version', 'X-Packaged-Version', 'X-Curse-Packaged-Version'] or '' + version_keys = ('Version', 'X-Packaged-Version', 'X-Curse-Packaged-Version') + return next(filter(None, map(self.toc_reader.get, version_keys)), '') def _get_unreconciled_folders(manager_ctx: manager_ctx.ManagerCtx): diff --git a/src/instawow/matchers/addon_toc.py b/src/instawow/matchers/addon_toc.py new file mode 100644 index 00000000..7448e867 --- /dev/null +++ b/src/instawow/matchers/addon_toc.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from collections.abc import Iterator, Mapping +from functools import cached_property +from pathlib import Path + +from typing_extensions import Self + + +class TocReader(Mapping[str, str]): + """Extracts key-value pairs from TOC files.""" + + def __init__(self, contents: str) -> None: + self._entries = { + k: v + for e in contents.splitlines() + if e.startswith('##') + for k, v in (map(str.strip, e.lstrip('#').partition(':')[::2]),) + if k + } + + def __iter__(self) -> Iterator[str]: + return iter(self._entries) + + def __getitem__(self, key: str, /) -> str: + return self._entries[key] + + def __len__(self) -> int: + return len(self._entries) + + @classmethod + def from_path(cls, path: Path) -> Self: + return cls(path.read_text(encoding='utf-8-sig', errors='replace')) + + @cached_property + def interfaces(self) -> list[int]: + interface = self.get('Interface') + return list(map(int, interface.split(','))) if interface else [] diff --git a/src/instawow/utils.py b/src/instawow/utils.py index 36d62ad9..02d44d33 100644 --- a/src/instawow/utils.py +++ b/src/instawow/utils.py @@ -13,6 +13,7 @@ Iterable, Iterator, Sequence, + Set, ) from contextlib import contextmanager from functools import partial, wraps @@ -54,29 +55,6 @@ def read_resource_as_text(package: ModuleType, resource: str, encoding: str = 'u return importlib.resources.files(package).joinpath(resource).read_text(encoding) -class TocReader: - """Extracts key-value pairs from TOC files.""" - - def __init__(self, contents: str) -> None: - self.entries = { - k: v - for e in contents.splitlines() - if e.startswith('##') - for k, v in (map(str.strip, e.lstrip('#').partition(':')[::2]),) - if k - } - - def __getitem__(self, key: str | tuple[str, ...]) -> str | None: - if isinstance(key, tuple): - return next(filter(None, map(self.entries.get, key)), None) - else: - return self.entries.get(key) - - @classmethod - def from_path(cls, path: Path) -> TocReader: - return cls(path.read_text(encoding='utf-8-sig', errors='replace')) - - def fill(it: Iterable[_T], fill: _T, length: int) -> Iterable[_T]: "Fill an iterable of specified length." return islice(chain(it, repeat(fill)), 0, length) diff --git a/tests/fixtures/http/__init__.py b/tests/fixtures/http/__init__.py index e8f278a2..b096d3ed 100644 --- a/tests/fixtures/http/__init__.py +++ b/tests/fixtures/http/__init__.py @@ -2,12 +2,12 @@ from __future__ import annotations +import importlib.resources import json import re from collections.abc import Awaitable, Callable from functools import cache from io import BytesIO -from pathlib import Path from typing import Any from zipfile import ZipFile @@ -17,13 +17,11 @@ from instawow import __version__ -_HERE = Path(__file__).parent - _match_any = re.compile(r'.*') def _load_fixture(filename: str): - return (_HERE / filename).read_bytes() + return (importlib.resources.files() / filename).read_bytes() def _load_json_fixture(filename: str): diff --git a/tests/test_addon_toc.py b/tests/test_addon_toc.py new file mode 100644 index 00000000..949ac076 --- /dev/null +++ b/tests/test_addon_toc.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import importlib.resources +from pathlib import Path + +import pytest + +from instawow.matchers.addon_toc import TocReader + + +@pytest.fixture +def fake_addon_toc(): + with importlib.resources.as_file( + importlib.resources.files() / 'fixtures' / 'FakeAddon' / 'FakeAddon.toc' + ) as file: + yield file + + +def test_loading_toc_from_path(fake_addon_toc: Path): + TocReader.from_path(fake_addon_toc) + with pytest.raises(FileNotFoundError): + TocReader.from_path(fake_addon_toc.parent / 'MissingAddon.toc') + + +def test_parsing_toc_entries(fake_addon_toc: Path): + toc_reader = TocReader.from_path(fake_addon_toc) + assert dict(toc_reader) == { + 'Normal': 'Normal entry', + 'Compact': 'Compact entry', + } + + +def test_toc_entry_indexing(fake_addon_toc: Path): + toc_reader = TocReader.from_path(fake_addon_toc) + assert toc_reader['Normal'] == 'Normal entry' + assert toc_reader['Compact'] == 'Compact entry' + assert toc_reader.get('Indented') is None + assert toc_reader.get('Comment') is None + assert toc_reader.get('Nonexistent') is None + + +def test_toc_interfaces(): + toc_reader = TocReader('## Interface: 10100') + assert toc_reader.interfaces == [10100] + + toc_reader = TocReader('## Interface: 10100, 20200') + assert toc_reader.interfaces == [10100, 20200] + + toc_reader = TocReader('## Interface: 10100 , 20200 ') + assert toc_reader.interfaces == [10100, 20200] diff --git a/tests/test_utils.py b/tests/test_utils.py index 7b3bd60e..1e79b558 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,14 +1,15 @@ from __future__ import annotations import asyncio +import importlib.resources import sys import time from pathlib import Path import pytest +from instawow.matchers.addon_toc import TocReader from instawow.utils import ( - TocReader, bucketise, file_uri_to_path, merge_intersecting_sets, @@ -19,39 +20,10 @@ @pytest.fixture def fake_addon_toc(): - return Path(__file__).parent / 'fixtures' / 'FakeAddon' / 'FakeAddon.toc' - - -def test_loading_toc_from_path(fake_addon_toc: Path): - TocReader.from_path(fake_addon_toc) - with pytest.raises(FileNotFoundError): - TocReader.from_path(fake_addon_toc.parent / 'MissingAddon.toc') - - -def test_parsing_toc_entries(fake_addon_toc: Path): - toc_reader = TocReader.from_path(fake_addon_toc) - assert toc_reader.entries == { - 'Normal': 'Normal entry', - 'Compact': 'Compact entry', - } - - -def test_toc_entry_indexing(fake_addon_toc: Path): - toc_reader = TocReader.from_path(fake_addon_toc) - assert toc_reader['Normal'] == 'Normal entry' - assert toc_reader['Compact'] == 'Compact entry' - assert toc_reader['Indented'] is None - assert toc_reader['Comment'] is None - assert toc_reader['Nonexistent'] is None - - -def test_toc_entry_multiindexing(fake_addon_toc: Path): - toc_reader = TocReader.from_path(fake_addon_toc) - assert toc_reader['Normal', 'Compact'] == 'Normal entry' - assert toc_reader['Compact', 'Normal'] == 'Compact entry' - assert toc_reader['Indented', 'Normal'] == 'Normal entry' - assert toc_reader['Nonexistent', 'Indented'] is None - assert toc_reader['Nonexistent', 'Indented', 'Normal'] == 'Normal entry' + with importlib.resources.as_file( + importlib.resources.files() / 'fixtures' / 'FakeAddon' / 'FakeAddon.toc' + ) as file: + yield file def test_bucketise_bucketises_by_putting_things_in_a_bucketing_bucket(): @@ -60,7 +32,7 @@ def test_bucketise_bucketises_by_putting_things_in_a_bucketing_bucket(): def test_tabulate_spits_out_ascii_table(fake_addon_toc: Path): toc_reader = TocReader.from_path(fake_addon_toc) - data = [('key', 'value'), *toc_reader.entries.items()] + data = [('key', 'value'), *toc_reader.items()] assert tabulate(data) == ( ' key value \n' '------- -------------\n'