From afb5b62fb742ccd3fd1f5d3a1c0469074a385e09 Mon Sep 17 00:00:00 2001 From: artcom Date: Sun, 12 Jul 2026 14:51:49 +0300 Subject: [PATCH 01/13] Fix formatting in README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d266d7f..e3f7907 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ roms/rom2.7z [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 262144 | CHR 262144] ROM matches: 2, Errors: 0 ``` -*See `romq nes search --help` for all available options.* + +See `romq nes search --help` for all available options. ### Library From 5ae41cdf2a7352dcf803ff216c85785802d5d0e0 Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 06:21:47 +0300 Subject: [PATCH 02/13] Tag hooks for CI --- prek.toml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/prek.toml b/prek.toml index f9802c5..602ecfb 100644 --- a/prek.toml +++ b/prek.toml @@ -2,14 +2,15 @@ repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v6.0.0" hooks = [ - { id = "trailing-whitespace", args = ["--markdown-linebreak-ext=md"] }, - { id = "end-of-file-fixer" }, - { id = "check-added-large-files" }, - { id = "check-case-conflict" }, - { id = "check-illegal-windows-names" }, - { id = "check-merge-conflict" }, - { id = "mixed-line-ending" }, - { id = "check-toml" }, + { id = "trailing-whitespace", args = ["--markdown-linebreak-ext=md"], groups = ["ci"] }, + { id = "end-of-file-fixer", groups = ["ci"] }, + { id = "check-added-large-files", groups = ["ci"] }, + { id = "check-case-conflict", groups = ["ci"] }, + { id = "check-illegal-windows-names", groups = ["ci"] }, + { id = "check-merge-conflict", groups = ["ci"] }, + { id = "mixed-line-ending", groups = ["ci"] }, + { id = "check-toml", groups = ["ci"] }, + { id = "check-yaml", files = "\\.(yml|yaml)$", groups = ["ci"] }, ] [[repos]] From 93098739a8f5c6e224bcde8f6c4048d0737dcdce Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 08:21:47 +0300 Subject: [PATCH 03/13] Fix line ending --- LICENSE | 42 +- README.md | 304 +++++----- prek.toml | 58 +- src/romq/__init__.py | 14 +- src/romq/app/__init__.py | 6 +- src/romq/app/errors.py | 20 +- src/romq/app/io/__init__.py | 10 +- src/romq/app/io/errors.py | 18 +- src/romq/app/io/file.py | 258 ++++----- src/romq/app/io/types.py | 6 +- src/romq/app/nes/__init__.py | 58 +- src/romq/app/nes/common.py | 10 +- src/romq/app/nes/inspect.py | 86 +-- src/romq/app/nes/models.py | 34 +- src/romq/app/nes/search.py | 398 ++++++------- src/romq/cli/__init__.py | 8 +- src/romq/cli/commands/nes.py | 214 +++---- src/romq/cli/main.py | 98 ++-- src/romq/cli/render.py | 230 ++++---- src/romq/cli/statuses.py | 8 +- src/romq/core/__init__.py | 6 +- src/romq/core/errors.py | 4 +- src/romq/core/nes/__init__.py | 6 +- src/romq/core/nes/errors.py | 10 +- src/romq/core/nes/parser/__init__.py | 40 +- src/romq/core/nes/parser/api.py | 346 +++++------ src/romq/core/nes/parser/common.py | 78 +-- src/romq/core/nes/parser/errors.py | 42 +- src/romq/core/nes/parser/ines.py | 98 ++-- src/romq/core/nes/parser/ines20.py | 306 +++++----- src/romq/core/nes/parser/models.py | 266 ++++----- src/romq/core/nes/parser/types.py | 28 +- tests/app/conftest.py | 32 +- tests/app/test_nes_inspect.py | 290 +++++----- tests/app/test_nes_search.py | 428 +++++++------- tests/app/utils.py | 110 ++-- tests/cli/conftest.py | 34 +- tests/cli/test_main.py | 114 ++-- tests/cli/test_nes.py | 98 ++-- tests/cli/test_nes_inspect.py | 328 +++++------ tests/cli/test_nes_search.py | 828 +++++++++++++-------------- tests/cli/utils.py | 62 +- tests/core/test_nes_parser.py | 812 +++++++++++++------------- 43 files changed, 3123 insertions(+), 3123 deletions(-) diff --git a/LICENSE b/LICENSE index 0f17e53..b55e640 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 artcom - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 artcom + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e3f7907..6b81920 100644 --- a/README.md +++ b/README.md @@ -1,152 +1,152 @@ -# romq - -[![Python](https://img.shields.io/badge/python-3.14+-blue)](https://python.org) -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -[![Status](https://img.shields.io/badge/status-WIP-yellow)]() - -`romq` is a library and CLI utility for ROM metadata inspection and search. - -## Table of contents -- [Features](#features) -- [Installation](#installation) -- [Usage](#usage) - - [CLI](#cli) - - [Library](#library) -- [License](#license) - -## Features - -- **Inspect**: Extract metadata from ROM files. -- **Search**: Scan a directory tree for ROMs by metadata (mapper, format, mirroring, etc.). -- **Supported systems**: - - **NES**: `iNES 1.0`, `iNES 2.0` (partial — bytes 13–15 not parsed yet). - - Support for other consoles is planned. - -## Installation - -### Requirements - -- Python 3.14+ - -### Install - -*TODO* - -## Usage - -### CLI - -**Inspect a ROM:** -```bash -romq nes inspect roms/rom.nes -``` - -*Output:* -``` -ROM: roms/rom.nes - Format iNES 2.0 - Console NES - TV System RP2C02 (NTSC NES) - Mapper 4 - Submapper 0 - Mirroring Horizontal - Trainer False - Alternate NT False - Battery-backed False - PRG ROM 131072 - CHR ROM 131072 - PRG RAM 0 - PRG NVRAM 0 - CHR RAM 0 - CHR NVRAM 0 -``` - -See `romq nes inspect --help` for all available options. - -**Search a directory tree with filters:** -```bash/ -romq nes search --mapper-id 4 ./roms -``` - -*Output:* -``` -roms/rom.zip - [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 131072 | CHR 131072] -roms/rom2.7z - [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 262144 | CHR 262144] -ROM matches: 2, Errors: 0 -``` - -See `romq nes search --help` for all available options. - -### Library - -`romq` is also available as a Python module. - -#### Inspect a ROM - -```python -import romq - -try: - rom_info = romq.nes.inspect_rom("roms/rom.nes") - # Or from a archive (ZIP, 7z) - # romq.nes.inspect_rom("roms/rom.zip") - - print(rom_info.mapper_id) # 4 - print(rom_info.rom_format) # RomFormat.INES_20 - print(rom_info.console_type) # ConsoleType.NES - # See `romq.nes.RomInfo` for all available fields -except romq.errors.RomqError as error: - print(f"ROM inspection error: {error}") -except OSError: - print("System error") -``` - -#### Search ROMs - -```python -import romq - -# Find all ROMs in a directory -result = romq.nes.search_roms("/roms") - -# Apply any combination of filters to narrow results -result = romq.nes.search_roms( - "/roms/nes", - query=romq.nes.SearchQuery( - mapper_id=4, - submapper_id=0, - rom_format=romq.nes.RomFormat.INES_20, - mirroring=romq.nes.Mirroring.HORIZONTAL, - console_type=romq.nes.ConsoleType.NES, - tv_system=romq.nes.TvSystem.NTSC, - has_battery=True, - has_trainer=False, - has_alternate_nt=False, - ) -) - -# Use a predicate for complex logic (field filters are ignored if predicate is given) -def custom_filter(metadata: romq.nes.RomMetadata) -> bool: - return ( - metadata.prg_rom_size > 16384 - and metadata.mapper_id in (1, 4) - and metadata.tv_system == romq.nes.TvSystem.NTSC - # See `romq.nes.RomMetadata` for all available fields - ) - -result = romq.nes.search_roms("/roms", predicate=custom_filter) - -# results.matches contains successful matches -for match in result.matches: - print(f"{match.filepath}: mapper_id={match.metadata.mapper_id}") - -# results.failures contains details about files that couldn't be parsed -for failure in result.failures: - print(f"Error: {failure.filepath}:{failure.error}") -``` - -## License - -MIT +# romq + +[![Python](https://img.shields.io/badge/python-3.14+-blue)](https://python.org) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Status](https://img.shields.io/badge/status-WIP-yellow)]() + +`romq` is a library and CLI utility for ROM metadata inspection and search. + +## Table of contents +- [Features](#features) +- [Installation](#installation) +- [Usage](#usage) + - [CLI](#cli) + - [Library](#library) +- [License](#license) + +## Features + +- **Inspect**: Extract metadata from ROM files. +- **Search**: Scan a directory tree for ROMs by metadata (mapper, format, mirroring, etc.). +- **Supported systems**: + - **NES**: `iNES 1.0`, `iNES 2.0` (partial — bytes 13–15 not parsed yet). + - Support for other consoles is planned. + +## Installation + +### Requirements + +- Python 3.14+ + +### Install + +*TODO* + +## Usage + +### CLI + +**Inspect a ROM:** +```bash +romq nes inspect roms/rom.nes +``` + +*Output:* +``` +ROM: roms/rom.nes + Format iNES 2.0 + Console NES + TV System RP2C02 (NTSC NES) + Mapper 4 + Submapper 0 + Mirroring Horizontal + Trainer False + Alternate NT False + Battery-backed False + PRG ROM 131072 + CHR ROM 131072 + PRG RAM 0 + PRG NVRAM 0 + CHR RAM 0 + CHR NVRAM 0 +``` + +See `romq nes inspect --help` for all available options. + +**Search a directory tree with filters:** +```bash/ +romq nes search --mapper-id 4 ./roms +``` + +*Output:* +``` +roms/rom.zip + [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 131072 | CHR 131072] +roms/rom2.7z + [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 262144 | CHR 262144] +ROM matches: 2, Errors: 0 +``` + +See `romq nes search --help` for all available options. + +### Library + +`romq` is also available as a Python module. + +#### Inspect a ROM + +```python +import romq + +try: + rom_info = romq.nes.inspect_rom("roms/rom.nes") + # Or from a archive (ZIP, 7z) + # romq.nes.inspect_rom("roms/rom.zip") + + print(rom_info.mapper_id) # 4 + print(rom_info.rom_format) # RomFormat.INES_20 + print(rom_info.console_type) # ConsoleType.NES + # See `romq.nes.RomInfo` for all available fields +except romq.errors.RomqError as error: + print(f"ROM inspection error: {error}") +except OSError: + print("System error") +``` + +#### Search ROMs + +```python +import romq + +# Find all ROMs in a directory +result = romq.nes.search_roms("/roms") + +# Apply any combination of filters to narrow results +result = romq.nes.search_roms( + "/roms/nes", + query=romq.nes.SearchQuery( + mapper_id=4, + submapper_id=0, + rom_format=romq.nes.RomFormat.INES_20, + mirroring=romq.nes.Mirroring.HORIZONTAL, + console_type=romq.nes.ConsoleType.NES, + tv_system=romq.nes.TvSystem.NTSC, + has_battery=True, + has_trainer=False, + has_alternate_nt=False, + ) +) + +# Use a predicate for complex logic (field filters are ignored if predicate is given) +def custom_filter(metadata: romq.nes.RomMetadata) -> bool: + return ( + metadata.prg_rom_size > 16384 + and metadata.mapper_id in (1, 4) + and metadata.tv_system == romq.nes.TvSystem.NTSC + # See `romq.nes.RomMetadata` for all available fields + ) + +result = romq.nes.search_roms("/roms", predicate=custom_filter) + +# results.matches contains successful matches +for match in result.matches: + print(f"{match.filepath}: mapper_id={match.metadata.mapper_id}") + +# results.failures contains details about files that couldn't be parsed +for failure in result.failures: + print(f"Error: {failure.filepath}:{failure.error}") +``` + +## License + +MIT diff --git a/prek.toml b/prek.toml index 602ecfb..98c8805 100644 --- a/prek.toml +++ b/prek.toml @@ -1,29 +1,29 @@ -[[repos]] -repo = "https://github.com/pre-commit/pre-commit-hooks" -rev = "v6.0.0" -hooks = [ - { id = "trailing-whitespace", args = ["--markdown-linebreak-ext=md"], groups = ["ci"] }, - { id = "end-of-file-fixer", groups = ["ci"] }, - { id = "check-added-large-files", groups = ["ci"] }, - { id = "check-case-conflict", groups = ["ci"] }, - { id = "check-illegal-windows-names", groups = ["ci"] }, - { id = "check-merge-conflict", groups = ["ci"] }, - { id = "mixed-line-ending", groups = ["ci"] }, - { id = "check-toml", groups = ["ci"] }, - { id = "check-yaml", files = "\\.(yml|yaml)$", groups = ["ci"] }, -] - -[[repos]] -repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.21" -hooks = [ - { id = "ruff-check", args = ["--fix"] }, - { id = "ruff-format" }, -] - -[[repos]] -repo = "https://github.com/DetachHead/basedpyright-prek-mirror" -rev = "1.39.9" -hooks = [ - { id = "basedpyright" }, -] +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v6.0.0" +hooks = [ + { id = "trailing-whitespace", args = ["--markdown-linebreak-ext=md"], groups = ["ci"] }, + { id = "end-of-file-fixer", groups = ["ci"] }, + { id = "check-added-large-files", groups = ["ci"] }, + { id = "check-case-conflict", groups = ["ci"] }, + { id = "check-illegal-windows-names", groups = ["ci"] }, + { id = "check-merge-conflict", groups = ["ci"] }, + { id = "mixed-line-ending", args=["--fix=lf"], groups = ["ci"] }, + { id = "check-toml", groups = ["ci"] }, + { id = "check-yaml", files = "\\.(yml|yaml)$", groups = ["ci"] }, +] + +[[repos]] +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.15.21" +hooks = [ + { id = "ruff-check", args = ["--fix"] }, + { id = "ruff-format" }, +] + +[[repos]] +repo = "https://github.com/DetachHead/basedpyright-prek-mirror" +rev = "1.39.9" +hooks = [ + { id = "basedpyright" }, +] diff --git a/src/romq/__init__.py b/src/romq/__init__.py index 2c21266..b1f5b99 100644 --- a/src/romq/__init__.py +++ b/src/romq/__init__.py @@ -1,7 +1,7 @@ -import importlib.metadata - -from romq.app import errors, nes - -__all__ = ("errors", "nes") - -__version__ = importlib.metadata.version(__name__) +import importlib.metadata + +from romq.app import errors, nes + +__all__ = ("errors", "nes") + +__version__ = importlib.metadata.version(__name__) diff --git a/src/romq/app/__init__.py b/src/romq/app/__init__.py index ec4d204..ce8baff 100644 --- a/src/romq/app/__init__.py +++ b/src/romq/app/__init__.py @@ -1,3 +1,3 @@ -from romq.app import errors, nes - -__all__ = ("errors", "nes") +from romq.app import errors, nes + +__all__ = ("errors", "nes") diff --git a/src/romq/app/errors.py b/src/romq/app/errors.py index 00354ed..16b7b28 100644 --- a/src/romq/app/errors.py +++ b/src/romq/app/errors.py @@ -1,10 +1,10 @@ -class RomqError(Exception): - """Base exception for all ROMQ errors.""" - - -class RomIOError(RomqError): - """Raised when a ROM file cannot be read or accessed.""" - - -class RomInspectError(RomqError): - """Raised when a ROM is invalid or cannot be parsed.""" +class RomqError(Exception): + """Base exception for all ROMQ errors.""" + + +class RomIOError(RomqError): + """Raised when a ROM file cannot be read or accessed.""" + + +class RomInspectError(RomqError): + """Raised when a ROM is invalid or cannot be parsed.""" diff --git a/src/romq/app/io/__init__.py b/src/romq/app/io/__init__.py index a420d02..dd49477 100644 --- a/src/romq/app/io/__init__.py +++ b/src/romq/app/io/__init__.py @@ -1,5 +1,5 @@ -from romq.app.io import errors -from romq.app.io.file import open_rom -from romq.app.io.types import PathType - -__all__ = ("errors", "open_rom", "PathType") +from romq.app.io import errors +from romq.app.io.file import open_rom +from romq.app.io.types import PathType + +__all__ = ("errors", "open_rom", "PathType") diff --git a/src/romq/app/io/errors.py b/src/romq/app/io/errors.py index 80df29e..aa2a970 100644 --- a/src/romq/app/io/errors.py +++ b/src/romq/app/io/errors.py @@ -1,9 +1,9 @@ -from romq.app.errors import RomIOError - - -class ArchiveError(RomIOError): - """Raised when the archive is invalid or cannot be processed.""" - - -class ArchiveMissingRom(RomIOError): - """Raised when the archive contains no matching ROM file.""" +from romq.app.errors import RomIOError + + +class ArchiveError(RomIOError): + """Raised when the archive is invalid or cannot be processed.""" + + +class ArchiveMissingRom(RomIOError): + """Raised when the archive contains no matching ROM file.""" diff --git a/src/romq/app/io/file.py b/src/romq/app/io/file.py index 9f9e0aa..ac323b6 100644 --- a/src/romq/app/io/file.py +++ b/src/romq/app/io/file.py @@ -1,129 +1,129 @@ -import contextlib -import io -import zipfile -from collections.abc import Generator -from pathlib import Path -from typing import Protocol, final, override - -import py7zr - -import romq.app.io.errors as io_errors -from romq.app.io.types import PathType - - -@contextlib.contextmanager -def open_rom( - filepath: PathType, - rom_extension: str, -) -> Generator[_BinaryStream]: - file_path = Path(filepath) - if not file_path.is_file(): - raise FileNotFoundError(f"No such file: {file_path}") - - with file_path.open("rb") as file: - if zipfile.is_zipfile(file): - with _extract_zip_stream(file, rom_extension) as rom_stream: - yield rom_stream - elif py7zr.is_7zfile(file): - with _extract_7zip_stream(file, rom_extension) as rom_stream: - yield rom_stream - else: - yield file - - -@contextlib.contextmanager -def _extract_zip_stream( - file: io.BufferedReader, - rom_extension: str, -) -> Generator[_BinaryStream]: - # TODO: return pair (stream + filename) instead of single stream - try: - with zipfile.ZipFile(file) as zip_file: - for filename in zip_file.namelist(): - if filename.endswith(rom_extension): - break - else: - raise io_errors.ArchiveMissingRom( - f"Archive doesn't contain ROM: *{rom_extension}" - ) - - with zip_file.open(filename) as stream: - yield stream - - except (zipfile.BadZipFile, zipfile.LargeZipFile) as error: - raise io_errors.ArchiveError(str(error)) from error - - -@contextlib.contextmanager -def _extract_7zip_stream( - file: io.BufferedReader, - rom_extension: str, -) -> Generator[_BinaryStream]: - try: - with py7zr.SevenZipFile(file, "r") as archive: - for filename in archive.namelist(): - if filename.endswith(rom_extension): - break - else: - raise io_errors.ArchiveMissingRom( - f"Archive doesn't contain ROM: *{rom_extension}" - ) - - factory = _MemoryIOFactory() - archive.extract(targets=[filename], factory=factory) - yield factory.stream - - except py7zr.Bad7zFile as error: - raise io_errors.ArchiveError(str(error)) from error - - -class _BinaryStream(Protocol): - def read(self, n: int = -1, /) -> bytes: ... - def seek(self, offset: int, whence: int = 0, /) -> int: ... - def tell(self) -> int: ... - - -@final -class _MemoryIO(py7zr.Py7zIO): - def __init__(self): - self._buffer = io.BytesIO() - - @property - def buffer(self) -> io.BytesIO: - return self._buffer - - @override - def write(self, s: bytes | bytearray) -> int: - return self._buffer.write(s) - - @override - def read(self, size: int | None = None) -> bytes: - return self._buffer.read(size) - - @override - def seek(self, offset: int, whence: int = 0) -> int: - return self._buffer.seek(offset, whence) - - @override - def flush(self) -> None: - return - - @override - def size(self) -> int: - return self._buffer.getbuffer().nbytes - - -class _MemoryIOFactory(py7zr.WriterFactory): - def __init__(self): - self._memory_io: _MemoryIO | None = None - - @override - def create(self, filename: str) -> py7zr.Py7zIO: - self._memory_io = _MemoryIO() - return self._memory_io - - @property - def stream(self) -> io.BytesIO: - if not self._memory_io: - raise ValueError("Internal buffer not exists") - return self._memory_io.buffer +import contextlib +import io +import zipfile +from collections.abc import Generator +from pathlib import Path +from typing import Protocol, final, override + +import py7zr + +import romq.app.io.errors as io_errors +from romq.app.io.types import PathType + + +@contextlib.contextmanager +def open_rom( + filepath: PathType, + rom_extension: str, +) -> Generator[_BinaryStream]: + file_path = Path(filepath) + if not file_path.is_file(): + raise FileNotFoundError(f"No such file: {file_path}") + + with file_path.open("rb") as file: + if zipfile.is_zipfile(file): + with _extract_zip_stream(file, rom_extension) as rom_stream: + yield rom_stream + elif py7zr.is_7zfile(file): + with _extract_7zip_stream(file, rom_extension) as rom_stream: + yield rom_stream + else: + yield file + + +@contextlib.contextmanager +def _extract_zip_stream( + file: io.BufferedReader, + rom_extension: str, +) -> Generator[_BinaryStream]: + # TODO: return pair (stream + filename) instead of single stream + try: + with zipfile.ZipFile(file) as zip_file: + for filename in zip_file.namelist(): + if filename.endswith(rom_extension): + break + else: + raise io_errors.ArchiveMissingRom( + f"Archive doesn't contain ROM: *{rom_extension}" + ) + + with zip_file.open(filename) as stream: + yield stream + + except (zipfile.BadZipFile, zipfile.LargeZipFile) as error: + raise io_errors.ArchiveError(str(error)) from error + + +@contextlib.contextmanager +def _extract_7zip_stream( + file: io.BufferedReader, + rom_extension: str, +) -> Generator[_BinaryStream]: + try: + with py7zr.SevenZipFile(file, "r") as archive: + for filename in archive.namelist(): + if filename.endswith(rom_extension): + break + else: + raise io_errors.ArchiveMissingRom( + f"Archive doesn't contain ROM: *{rom_extension}" + ) + + factory = _MemoryIOFactory() + archive.extract(targets=[filename], factory=factory) + yield factory.stream + + except py7zr.Bad7zFile as error: + raise io_errors.ArchiveError(str(error)) from error + + +class _BinaryStream(Protocol): + def read(self, n: int = -1, /) -> bytes: ... + def seek(self, offset: int, whence: int = 0, /) -> int: ... + def tell(self) -> int: ... + + +@final +class _MemoryIO(py7zr.Py7zIO): + def __init__(self): + self._buffer = io.BytesIO() + + @property + def buffer(self) -> io.BytesIO: + return self._buffer + + @override + def write(self, s: bytes | bytearray) -> int: + return self._buffer.write(s) + + @override + def read(self, size: int | None = None) -> bytes: + return self._buffer.read(size) + + @override + def seek(self, offset: int, whence: int = 0) -> int: + return self._buffer.seek(offset, whence) + + @override + def flush(self) -> None: + return + + @override + def size(self) -> int: + return self._buffer.getbuffer().nbytes + + +class _MemoryIOFactory(py7zr.WriterFactory): + def __init__(self): + self._memory_io: _MemoryIO | None = None + + @override + def create(self, filename: str) -> py7zr.Py7zIO: + self._memory_io = _MemoryIO() + return self._memory_io + + @property + def stream(self) -> io.BytesIO: + if not self._memory_io: + raise ValueError("Internal buffer not exists") + return self._memory_io.buffer diff --git a/src/romq/app/io/types.py b/src/romq/app/io/types.py index e13336f..10eaa3e 100644 --- a/src/romq/app/io/types.py +++ b/src/romq/app/io/types.py @@ -1,3 +1,3 @@ -import os - -type PathType = str | os.PathLike[str] +import os + +type PathType = str | os.PathLike[str] diff --git a/src/romq/app/nes/__init__.py b/src/romq/app/nes/__init__.py index f81e82c..7b4982c 100644 --- a/src/romq/app/nes/__init__.py +++ b/src/romq/app/nes/__init__.py @@ -1,29 +1,29 @@ -from romq.app.nes.inspect import inspect_rom -from romq.app.nes.models import RomInfo -from romq.app.nes.search import ( - InspectionFailure, - SearchQuery, - SearchResult, - search_roms, -) -from romq.core.nes.parser import ( - ConsoleType, - Mirroring, - RomFormat, - RomMetadata, - TvSystem, -) - -__all__ = ( - "inspect_rom", - "search_roms", - "InspectionFailure", - "SearchQuery", - "SearchResult", - "ConsoleType", - "Mirroring", - "RomFormat", - "RomInfo", - "TvSystem", - "RomMetadata", -) +from romq.app.nes.inspect import inspect_rom +from romq.app.nes.models import RomInfo +from romq.app.nes.search import ( + InspectionFailure, + SearchQuery, + SearchResult, + search_roms, +) +from romq.core.nes.parser import ( + ConsoleType, + Mirroring, + RomFormat, + RomMetadata, + TvSystem, +) + +__all__ = ( + "inspect_rom", + "search_roms", + "InspectionFailure", + "SearchQuery", + "SearchResult", + "ConsoleType", + "Mirroring", + "RomFormat", + "RomInfo", + "TvSystem", + "RomMetadata", +) diff --git a/src/romq/app/nes/common.py b/src/romq/app/nes/common.py index e947abd..7facbc1 100644 --- a/src/romq/app/nes/common.py +++ b/src/romq/app/nes/common.py @@ -1,5 +1,5 @@ -import functools - -import romq.app.io as romq_io - -open_rom = functools.partial(romq_io.open_rom, rom_extension=".nes") +import functools + +import romq.app.io as romq_io + +open_rom = functools.partial(romq_io.open_rom, rom_extension=".nes") diff --git a/src/romq/app/nes/inspect.py b/src/romq/app/nes/inspect.py index 64b51a1..7b6c9e5 100644 --- a/src/romq/app/nes/inspect.py +++ b/src/romq/app/nes/inspect.py @@ -1,43 +1,43 @@ -from pathlib import Path - -from romq.app.errors import RomInspectError -from romq.app.io import PathType -from romq.app.nes.common import open_rom -from romq.app.nes.models import RomInfo -from romq.core.nes import parser as nes_parser - - -def inspect_rom(rom_path: PathType) -> RomInfo: - """Inspect an NES ROM file and return its metadata. - - Opens the specified ROM file (plain file, ZIP/7z archive) and returns - a :class:`~romq.app.nes.RomInfo` with all detected properties. - If the archive contains multiple ROM files, the first one found with - the correct extension will be inspected. - - Args: - rom_path: String or path-like object to the ROM file or a ZIP/7z - archive containing one. - - Returns: - A :class:`~romq.app.nes.RomInfo` instance with the ROM metadata. - - Raises: - :exc:`~romq.app.errors.RomInspectError`: If the file is not a valid - NES ROM or the header cannot be parsed. - :exc:`~romq.app.io.errors.ArchiveError`: If the archive is invalid or - cannot be processed. - :exc:`~romq.app.io.errors.ArchiveMissingRom`: If the archive contains - no matching ROM file. - OSError: If there is a system-level issue accessing the file - (e.g. file not found, permission denied, or path is not a file). - """ - filepath = Path(rom_path) - try: - with open_rom(filepath) as file: - metadata = nes_parser.parse_stream(file) - return RomInfo(filepath=filepath, metadata=metadata) - except nes_parser.errors.ParseError as error: - raise RomInspectError( - f"Failed to parse ROM header: {error}" - ) from error +from pathlib import Path + +from romq.app.errors import RomInspectError +from romq.app.io import PathType +from romq.app.nes.common import open_rom +from romq.app.nes.models import RomInfo +from romq.core.nes import parser as nes_parser + + +def inspect_rom(rom_path: PathType) -> RomInfo: + """Inspect an NES ROM file and return its metadata. + + Opens the specified ROM file (plain file, ZIP/7z archive) and returns + a :class:`~romq.app.nes.RomInfo` with all detected properties. + If the archive contains multiple ROM files, the first one found with + the correct extension will be inspected. + + Args: + rom_path: String or path-like object to the ROM file or a ZIP/7z + archive containing one. + + Returns: + A :class:`~romq.app.nes.RomInfo` instance with the ROM metadata. + + Raises: + :exc:`~romq.app.errors.RomInspectError`: If the file is not a valid + NES ROM or the header cannot be parsed. + :exc:`~romq.app.io.errors.ArchiveError`: If the archive is invalid or + cannot be processed. + :exc:`~romq.app.io.errors.ArchiveMissingRom`: If the archive contains + no matching ROM file. + OSError: If there is a system-level issue accessing the file + (e.g. file not found, permission denied, or path is not a file). + """ + filepath = Path(rom_path) + try: + with open_rom(filepath) as file: + metadata = nes_parser.parse_stream(file) + return RomInfo(filepath=filepath, metadata=metadata) + except nes_parser.errors.ParseError as error: + raise RomInspectError( + f"Failed to parse ROM header: {error}" + ) from error diff --git a/src/romq/app/nes/models.py b/src/romq/app/nes/models.py index ce7a9b3..d647695 100644 --- a/src/romq/app/nes/models.py +++ b/src/romq/app/nes/models.py @@ -1,17 +1,17 @@ -from dataclasses import dataclass -from pathlib import Path - -from romq.core.nes import parser as nes_parser - - -@dataclass(slots=True, frozen=True) -class RomInfo: - """Information about an NES ROM file. - - Attributes: - filepath: The path to the source file or archive containing the ROM. - metadata: The metadata extracted from the ROM. - """ - - filepath: Path - metadata: nes_parser.RomMetadata +from dataclasses import dataclass +from pathlib import Path + +from romq.core.nes import parser as nes_parser + + +@dataclass(slots=True, frozen=True) +class RomInfo: + """Information about an NES ROM file. + + Attributes: + filepath: The path to the source file or archive containing the ROM. + metadata: The metadata extracted from the ROM. + """ + + filepath: Path + metadata: nes_parser.RomMetadata diff --git a/src/romq/app/nes/search.py b/src/romq/app/nes/search.py index bb7ecc3..0e52ec1 100644 --- a/src/romq/app/nes/search.py +++ b/src/romq/app/nes/search.py @@ -1,199 +1,199 @@ -from collections.abc import Callable, Iterable -from dataclasses import dataclass -from pathlib import Path -from typing import override - -from romq.app.io import PathType -from romq.app.io import errors as io_errors -from romq.app.nes.common import open_rom -from romq.app.nes.models import RomInfo -from romq.core.nes import parser as nes_parser - - -@dataclass(slots=True, frozen=True, kw_only=True) -class SearchQuery: - """Criteria for filtering NES ROMs during a directory search. - - Attributes: - mapper_id: Filter by mapper ID (high and low bits combined). - submapper_id: Filter by submapper ID. - rom_format: Filter by ROM format (e.g. iNES, iNES 2.0). - mirroring: Filter by mirroring type (vertical or horizontal). - console_type: Filter by console (NES, VS Unisystem, PlayChoice-10). - tv_system: Filter by TV system (NTSC, PAL, Dendy, Multiple-region). - has_battery: Filter by presence of battery-backed save memory. - has_trainer: Filter by presence of a trainer program. - has_alternate_nt: Filter by presence of alternate nametable layout. - """ - - mapper_id: int | None = None - submapper_id: int | None = None - rom_format: nes_parser.RomFormat | None = None - mirroring: nes_parser.Mirroring | None = None - console_type: nes_parser.ConsoleType | None = None - tv_system: nes_parser.TvSystem | None = None - has_battery: bool | None = None - has_trainer: bool | None = None - has_alternate_nt: bool | None = None - - -@dataclass(slots=True, frozen=True) -class InspectionFailure: - """A ROM file that could not be inspected. - - Attributes: - filepath: The path to the source file or archive that failed - inspection. - error: The exception raised during inspection. - """ - - filepath: Path - error: Exception - - -@dataclass(slots=True, frozen=True) -class SearchResult: - """Results from searching for NES ROMs in a directory tree. - - Attributes: - matches: List of ROMs that matched all specified filter criteria. - failures: List of ROMs that could not be inspected due to errors. - """ - - matches: list[RomInfo] - failures: list[InspectionFailure] - - -def search_roms( - root_dir: PathType, - query: SearchQuery | None = None, - predicate: Callable[[nes_parser.RomMetadata], bool] | None = None, -) -> SearchResult: - """Search a directory tree for NES ROMs matching the given criteria. - - Recursively scans all files under ``root_dir``, identifies NES ROMs - (including ROMs packaged inside archives (ZIP/7z)), parses their - headers, and filters them by the provided criteria. The results include - the path to the source file or archive containing it. - - Files that cannot be read or parsed are recorded as failures rather than - raising an exception, so all successfully inspected ROMs are returned - along with the list of failures. - - Args: - root_dir: String or path-like object to the root directory to search. - query: A :class:`~romq.app.nes.SearchQuery` object containing - the filtering criteria. - predicate: Optional callback function accepting a - :class:`~romq.app.nes.RomMetadata` and returning ``True`` for - matches. If provided, all other filter parameters in ``query`` - are ignored. - - Returns: - A :class:`~romq.app.nes.SearchResult` containing a list of - :class:`~romq.app.nes.RomInfo` objects and a list of - :class:`~romq.app.nes.RomInspectFailure` objects. - - Raises: - OSError: If there is a system-level issue accessing the root directory - (e.g. directory not found, permission denied, etc.). - """ - root_dir_path = Path(root_dir) - if not root_dir_path.exists(): - raise FileNotFoundError(f"No such directory: {root_dir_path}") - if not root_dir_path.is_dir(): - raise NotADirectoryError(f"Directory name is invalid: {root_dir_path}") - - match_func = _make_predicate(query) if predicate is None else predicate - - matches: list[RomInfo] = [] - errors: list[InspectionFailure] = [] - - for filepath in _generate_filepaths(root_dir_path): - try: - with open_rom(filepath) as file: - if not nes_parser.is_rom(file): - continue - rom_metadata = nes_parser.parse_stream(file) - except io_errors.ArchiveMissingRom: - continue - except ( - OSError, - io_errors.ArchiveError, - nes_parser.errors.ParseError, - ) as error: - errors.append(InspectionFailure(filepath, error)) - continue - - if match_func(rom_metadata): - matches.append(RomInfo(filepath, rom_metadata)) - - return SearchResult(matches, errors) - - -@dataclass(slots=True, frozen=True, eq=False, init=False) -class _Wildcard: # noqa: PLW1641 - @override - def __eq__(self, value: object, /) -> bool: - return True - - -_WILDCARD = _Wildcard() - - -def _make_predicate( - query: SearchQuery | None, -) -> Callable[[nes_parser.RomMetadata], bool]: - if not query: - return lambda _: True - - # fmt: off - _mapper_id = ( - _WILDCARD if query.mapper_id is None else query.mapper_id - ) - _submapper_id = ( - _WILDCARD if query.submapper_id is None else query.submapper_id - ) - _rom_format = ( - _WILDCARD if query.rom_format is None else query.rom_format - ) - _mirroring = ( - _WILDCARD if query.mirroring is None else query.mirroring - ) - _console_type = ( - _WILDCARD if query.console_type is None else query.console_type - ) - _tv_system = ( - _WILDCARD if query.tv_system is None else query.tv_system - ) - _has_battery = ( - _WILDCARD if query.has_battery is None else query.has_battery - ) - _has_trainer = ( - _WILDCARD if query.has_trainer is None else query.has_trainer - ) - _has_alt_nt = ( - _WILDCARD if query.has_alternate_nt is None else query.has_alternate_nt - ) - # fmt: on - - def match_rom(rom_info: nes_parser.RomMetadata) -> bool: - return ( - rom_info.mapper_id == _mapper_id - and rom_info.submapper_id == _submapper_id - and rom_info.rom_format == _rom_format - and rom_info.mirroring == _mirroring - and rom_info.console_type == _console_type - and rom_info.tv_system == _tv_system - and rom_info.has_battery == _has_battery - and rom_info.has_trainer == _has_trainer - and rom_info.has_alternate_nt == _has_alt_nt - ) - - return match_rom - - -def _generate_filepaths(root: Path) -> Iterable[Path]: - for root_dir, _, files in root.walk(): - for file in files: - yield root_dir.joinpath(file) +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from pathlib import Path +from typing import override + +from romq.app.io import PathType +from romq.app.io import errors as io_errors +from romq.app.nes.common import open_rom +from romq.app.nes.models import RomInfo +from romq.core.nes import parser as nes_parser + + +@dataclass(slots=True, frozen=True, kw_only=True) +class SearchQuery: + """Criteria for filtering NES ROMs during a directory search. + + Attributes: + mapper_id: Filter by mapper ID (high and low bits combined). + submapper_id: Filter by submapper ID. + rom_format: Filter by ROM format (e.g. iNES, iNES 2.0). + mirroring: Filter by mirroring type (vertical or horizontal). + console_type: Filter by console (NES, VS Unisystem, PlayChoice-10). + tv_system: Filter by TV system (NTSC, PAL, Dendy, Multiple-region). + has_battery: Filter by presence of battery-backed save memory. + has_trainer: Filter by presence of a trainer program. + has_alternate_nt: Filter by presence of alternate nametable layout. + """ + + mapper_id: int | None = None + submapper_id: int | None = None + rom_format: nes_parser.RomFormat | None = None + mirroring: nes_parser.Mirroring | None = None + console_type: nes_parser.ConsoleType | None = None + tv_system: nes_parser.TvSystem | None = None + has_battery: bool | None = None + has_trainer: bool | None = None + has_alternate_nt: bool | None = None + + +@dataclass(slots=True, frozen=True) +class InspectionFailure: + """A ROM file that could not be inspected. + + Attributes: + filepath: The path to the source file or archive that failed + inspection. + error: The exception raised during inspection. + """ + + filepath: Path + error: Exception + + +@dataclass(slots=True, frozen=True) +class SearchResult: + """Results from searching for NES ROMs in a directory tree. + + Attributes: + matches: List of ROMs that matched all specified filter criteria. + failures: List of ROMs that could not be inspected due to errors. + """ + + matches: list[RomInfo] + failures: list[InspectionFailure] + + +def search_roms( + root_dir: PathType, + query: SearchQuery | None = None, + predicate: Callable[[nes_parser.RomMetadata], bool] | None = None, +) -> SearchResult: + """Search a directory tree for NES ROMs matching the given criteria. + + Recursively scans all files under ``root_dir``, identifies NES ROMs + (including ROMs packaged inside archives (ZIP/7z)), parses their + headers, and filters them by the provided criteria. The results include + the path to the source file or archive containing it. + + Files that cannot be read or parsed are recorded as failures rather than + raising an exception, so all successfully inspected ROMs are returned + along with the list of failures. + + Args: + root_dir: String or path-like object to the root directory to search. + query: A :class:`~romq.app.nes.SearchQuery` object containing + the filtering criteria. + predicate: Optional callback function accepting a + :class:`~romq.app.nes.RomMetadata` and returning ``True`` for + matches. If provided, all other filter parameters in ``query`` + are ignored. + + Returns: + A :class:`~romq.app.nes.SearchResult` containing a list of + :class:`~romq.app.nes.RomInfo` objects and a list of + :class:`~romq.app.nes.RomInspectFailure` objects. + + Raises: + OSError: If there is a system-level issue accessing the root directory + (e.g. directory not found, permission denied, etc.). + """ + root_dir_path = Path(root_dir) + if not root_dir_path.exists(): + raise FileNotFoundError(f"No such directory: {root_dir_path}") + if not root_dir_path.is_dir(): + raise NotADirectoryError(f"Directory name is invalid: {root_dir_path}") + + match_func = _make_predicate(query) if predicate is None else predicate + + matches: list[RomInfo] = [] + errors: list[InspectionFailure] = [] + + for filepath in _generate_filepaths(root_dir_path): + try: + with open_rom(filepath) as file: + if not nes_parser.is_rom(file): + continue + rom_metadata = nes_parser.parse_stream(file) + except io_errors.ArchiveMissingRom: + continue + except ( + OSError, + io_errors.ArchiveError, + nes_parser.errors.ParseError, + ) as error: + errors.append(InspectionFailure(filepath, error)) + continue + + if match_func(rom_metadata): + matches.append(RomInfo(filepath, rom_metadata)) + + return SearchResult(matches, errors) + + +@dataclass(slots=True, frozen=True, eq=False, init=False) +class _Wildcard: # noqa: PLW1641 + @override + def __eq__(self, value: object, /) -> bool: + return True + + +_WILDCARD = _Wildcard() + + +def _make_predicate( + query: SearchQuery | None, +) -> Callable[[nes_parser.RomMetadata], bool]: + if not query: + return lambda _: True + + # fmt: off + _mapper_id = ( + _WILDCARD if query.mapper_id is None else query.mapper_id + ) + _submapper_id = ( + _WILDCARD if query.submapper_id is None else query.submapper_id + ) + _rom_format = ( + _WILDCARD if query.rom_format is None else query.rom_format + ) + _mirroring = ( + _WILDCARD if query.mirroring is None else query.mirroring + ) + _console_type = ( + _WILDCARD if query.console_type is None else query.console_type + ) + _tv_system = ( + _WILDCARD if query.tv_system is None else query.tv_system + ) + _has_battery = ( + _WILDCARD if query.has_battery is None else query.has_battery + ) + _has_trainer = ( + _WILDCARD if query.has_trainer is None else query.has_trainer + ) + _has_alt_nt = ( + _WILDCARD if query.has_alternate_nt is None else query.has_alternate_nt + ) + # fmt: on + + def match_rom(rom_info: nes_parser.RomMetadata) -> bool: + return ( + rom_info.mapper_id == _mapper_id + and rom_info.submapper_id == _submapper_id + and rom_info.rom_format == _rom_format + and rom_info.mirroring == _mirroring + and rom_info.console_type == _console_type + and rom_info.tv_system == _tv_system + and rom_info.has_battery == _has_battery + and rom_info.has_trainer == _has_trainer + and rom_info.has_alternate_nt == _has_alt_nt + ) + + return match_rom + + +def _generate_filepaths(root: Path) -> Iterable[Path]: + for root_dir, _, files in root.walk(): + for file in files: + yield root_dir.joinpath(file) diff --git a/src/romq/cli/__init__.py b/src/romq/cli/__init__.py index 3db6bb0..a486470 100644 --- a/src/romq/cli/__init__.py +++ b/src/romq/cli/__init__.py @@ -1,4 +1,4 @@ -from romq.cli import statuses -from romq.cli.main import main - -__all__ = ("main", "statuses") +from romq.cli import statuses +from romq.cli.main import main + +__all__ = ("main", "statuses") diff --git a/src/romq/cli/commands/nes.py b/src/romq/cli/commands/nes.py index e02b442..126ebba 100644 --- a/src/romq/cli/commands/nes.py +++ b/src/romq/cli/commands/nes.py @@ -1,107 +1,107 @@ -import click - -from romq.app import nes as nes_app - -nes_group = click.Group("nes", help="NES ROM inspection and search utilities") - - -@nes_group.command( - "inspect", - help="Display metadata for a single NES ROM file or an archive (ZIP/7z).", -) -@click.argument("rom_path") -def _inspect_rom(rom_path: str) -> nes_app.RomInfo: # pyright: ignore[reportUnusedFunction] - return nes_app.inspect_rom(rom_path) - - -@nes_group.command( - "search", - help="Search NES ROMs in a directory using hardware and format filters.", -) -@click.argument("root_dir") -@click.option( - "-m", - "--mapper-id", - type=click.INT, - help="Mapper ID (e.g., 0, 1, 4)", -) -@click.option( - "-s", - "--submapper-id", - type=click.INT, - help="Submapper ID", -) -@click.option( - "-f", - "--rom-format", - type=click.Choice( - (nes_app.RomFormat.INES, nes_app.RomFormat.INES_20), - case_sensitive=False, - ), - help="iNES format version (1.0 or 2.0)", -) -@click.option( - "-r", - "--mirroring", - type=click.Choice(nes_app.Mirroring, case_sensitive=False), - help="Mirroring mode", -) -@click.option( - "-c", - "--console", - type=click.Choice(nes_app.ConsoleType, case_sensitive=False), - help="Console type", -) -@click.option( - "-v", - "--tv-system", - type=click.Choice(nes_app.TvSystem, case_sensitive=False), - help="TV system", -) -@click.option( - "--battery/--no-battery", - "has_battery", - is_flag=True, - default=None, - help="Battery-backed RAM", -) -@click.option( - "--trainer/--no-trainer", - "has_trainer", - is_flag=True, - default=None, - help="Trainer present", -) -@click.option( - "--alt-nt/--no-alt-nt", - "has_alternate_nt", - is_flag=True, - default=None, - help="Alternate nametable layout", -) -def _search_roms( # pyright: ignore[reportUnusedFunction] # noqa: PLR0913 - root_dir: str, - mapper_id: int | None, - submapper_id: int | None, - rom_format: nes_app.RomFormat | None, - mirroring: nes_app.Mirroring | None, - console: nes_app.ConsoleType | None, - tv_system: nes_app.TvSystem | None, - has_battery: bool | None, - has_trainer: bool | None, - has_alternate_nt: bool | None, -) -> nes_app.SearchResult: - return nes_app.search_roms( - root_dir, - query=nes_app.SearchQuery( - mapper_id=mapper_id, - submapper_id=submapper_id, - rom_format=rom_format, - mirroring=mirroring, - console_type=console, - tv_system=tv_system, - has_battery=has_battery, - has_trainer=has_trainer, - has_alternate_nt=has_alternate_nt, - ), - ) +import click + +from romq.app import nes as nes_app + +nes_group = click.Group("nes", help="NES ROM inspection and search utilities") + + +@nes_group.command( + "inspect", + help="Display metadata for a single NES ROM file or an archive (ZIP/7z).", +) +@click.argument("rom_path") +def _inspect_rom(rom_path: str) -> nes_app.RomInfo: # pyright: ignore[reportUnusedFunction] + return nes_app.inspect_rom(rom_path) + + +@nes_group.command( + "search", + help="Search NES ROMs in a directory using hardware and format filters.", +) +@click.argument("root_dir") +@click.option( + "-m", + "--mapper-id", + type=click.INT, + help="Mapper ID (e.g., 0, 1, 4)", +) +@click.option( + "-s", + "--submapper-id", + type=click.INT, + help="Submapper ID", +) +@click.option( + "-f", + "--rom-format", + type=click.Choice( + (nes_app.RomFormat.INES, nes_app.RomFormat.INES_20), + case_sensitive=False, + ), + help="iNES format version (1.0 or 2.0)", +) +@click.option( + "-r", + "--mirroring", + type=click.Choice(nes_app.Mirroring, case_sensitive=False), + help="Mirroring mode", +) +@click.option( + "-c", + "--console", + type=click.Choice(nes_app.ConsoleType, case_sensitive=False), + help="Console type", +) +@click.option( + "-v", + "--tv-system", + type=click.Choice(nes_app.TvSystem, case_sensitive=False), + help="TV system", +) +@click.option( + "--battery/--no-battery", + "has_battery", + is_flag=True, + default=None, + help="Battery-backed RAM", +) +@click.option( + "--trainer/--no-trainer", + "has_trainer", + is_flag=True, + default=None, + help="Trainer present", +) +@click.option( + "--alt-nt/--no-alt-nt", + "has_alternate_nt", + is_flag=True, + default=None, + help="Alternate nametable layout", +) +def _search_roms( # pyright: ignore[reportUnusedFunction] # noqa: PLR0913 + root_dir: str, + mapper_id: int | None, + submapper_id: int | None, + rom_format: nes_app.RomFormat | None, + mirroring: nes_app.Mirroring | None, + console: nes_app.ConsoleType | None, + tv_system: nes_app.TvSystem | None, + has_battery: bool | None, + has_trainer: bool | None, + has_alternate_nt: bool | None, +) -> nes_app.SearchResult: + return nes_app.search_roms( + root_dir, + query=nes_app.SearchQuery( + mapper_id=mapper_id, + submapper_id=submapper_id, + rom_format=rom_format, + mirroring=mirroring, + console_type=console, + tv_system=tv_system, + has_battery=has_battery, + has_trainer=has_trainer, + has_alternate_nt=has_alternate_nt, + ), + ) diff --git a/src/romq/cli/main.py b/src/romq/cli/main.py index bea7b4e..92e48ae 100644 --- a/src/romq/cli/main.py +++ b/src/romq/cli/main.py @@ -1,49 +1,49 @@ -from typing import Any, override - -import click - -from romq import __version__ -from romq.app import errors as app_errors -from romq.cli.commands.nes import nes_group -from romq.cli.render import Renderable, render -from romq.cli.statuses import ES_ERROR, ES_INTERNAL_ERROR - - -class _MainGroup(click.Group): - def __init__(self, *args, **kwargs) -> None: # pyright: ignore[reportMissingParameterType, reportUnknownParameterType] - super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] - self.add_command(nes_group) - - @override - def invoke(self, ctx: click.Context) -> Any: # pyright: ignore[reportExplicitAny, reportAny] - try: - return super().invoke(ctx) # pyright: ignore[reportAny] - except click.ClickException, click.Abort, click.exceptions.Exit: - raise - except (OSError, app_errors.RomqError) as error: - status = ES_ERROR - click.echo(render(error), err=True) - except Exception as error: - status = ES_INTERNAL_ERROR - click.echo(render(error), err=True) - - raise click.exceptions.Exit(status) - - -@click.group( - "romq", - cls=_MainGroup, - help=( - "A CLI utility for inspecting ROM metadata and searching directories" - " for ROM files based on specific criteria." - ), - context_settings={"help_option_names": ["-h", "--help"], "color": True}, -) -@click.version_option(message=f"%(prog)s {__version__}") -def main() -> None: - pass - - -@main.result_callback() -def _process_result(result: Renderable) -> None: # pyright: ignore[reportUnusedFunction] - click.echo(render(result)) +from typing import Any, override + +import click + +from romq import __version__ +from romq.app import errors as app_errors +from romq.cli.commands.nes import nes_group +from romq.cli.render import Renderable, render +from romq.cli.statuses import ES_ERROR, ES_INTERNAL_ERROR + + +class _MainGroup(click.Group): + def __init__(self, *args, **kwargs) -> None: # pyright: ignore[reportMissingParameterType, reportUnknownParameterType] + super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] + self.add_command(nes_group) + + @override + def invoke(self, ctx: click.Context) -> Any: # pyright: ignore[reportExplicitAny, reportAny] + try: + return super().invoke(ctx) # pyright: ignore[reportAny] + except click.ClickException, click.Abort, click.exceptions.Exit: + raise + except (OSError, app_errors.RomqError) as error: + status = ES_ERROR + click.echo(render(error), err=True) + except Exception as error: + status = ES_INTERNAL_ERROR + click.echo(render(error), err=True) + + raise click.exceptions.Exit(status) + + +@click.group( + "romq", + cls=_MainGroup, + help=( + "A CLI utility for inspecting ROM metadata and searching directories" + " for ROM files based on specific criteria." + ), + context_settings={"help_option_names": ["-h", "--help"], "color": True}, +) +@click.version_option(message=f"%(prog)s {__version__}") +def main() -> None: + pass + + +@main.result_callback() +def _process_result(result: Renderable) -> None: # pyright: ignore[reportUnusedFunction] + click.echo(render(result)) diff --git a/src/romq/cli/render.py b/src/romq/cli/render.py index e90f73e..c94a1a1 100644 --- a/src/romq/cli/render.py +++ b/src/romq/cli/render.py @@ -1,115 +1,115 @@ -import functools -from typing import overload - -import click - -from romq.app import nes as nes_app - -type Renderable = nes_app.RomInfo | nes_app.SearchResult | Exception - - -@overload -def render(data: nes_app.RomInfo) -> str: ... - - -@overload -def render(data: nes_app.SearchResult) -> str: ... - - -@overload -def render(data: Exception) -> str: ... - - -def render(data: Renderable) -> str: - return _render(data) - - -@functools.singledispatch -def _render(data: Renderable) -> str: - raise NotImplementedError(f"No renderer for {type(data)}") - - -@_render.register -def _render_error(data: Exception) -> str: # pyright: ignore[reportUnusedFunction] - return click.style(f"{data}", fg="bright_red") - - -@_render.register -def _render_rom_info(data: nes_app.RomInfo) -> str: # pyright: ignore[reportUnusedFunction] - rows: list[str] = [click.style(f"ROM: {data.filepath}", fg="bright_cyan")] - - metadata = data.metadata - raw_rows = ( - ("Format", metadata.rom_format.describe()), - ("Console", metadata.console_type.describe()), - ("TV System", metadata.tv_system.describe()), - ("Mapper", str(metadata.mapper_id)), - ("Submapper", str(metadata.submapper_id)), - ("Mirroring", metadata.mirroring.describe()), - ("Trainer", str(metadata.has_trainer)), - ("Alternate NT", str(metadata.has_alternate_nt)), - ("Battery-backed", str(metadata.has_battery)), - ("PRG ROM", str(metadata.prg_rom_size)), - ("CHR ROM", str(metadata.chr_rom_size)), - ("PRG RAM", str(metadata.prg_ram_size)), - ("PRG NVRAM", str(metadata.prg_nvram_size)), - ("CHR RAM", str(metadata.chr_ram_size)), - ("CHR NVRAM", str(metadata.chr_nvram_size)), - ) - - max_label_len = max(len(label) for label, _ in raw_rows) - label_width = max_label_len + 2 - - for label, value in raw_rows: - styled_label = click.style( - f" {label:<{label_width}}", - fg="bright_black", - ) - styled_value = click.style( - f"{value}", - fg="bright_white", - ) - rows.append(" ".join((styled_label, styled_value))) - - return "\n".join(rows) - - -@_render.register -def _render_search_result(data: nes_app.SearchResult) -> str: # pyright: ignore[reportUnusedFunction] - rows: list[str] = [] - - for match in data.matches: - rows.append(click.style(str(match.filepath), fg="bright_green")) - - metadata = match.metadata - rows.append( - click.style( - ( - f" [{metadata.rom_format.describe()}" - f" | {metadata.tv_system.describe()}" - f" | Mapper {metadata.mapper_id}" - f" | {metadata.mirroring.describe()}" - f" | PRG {metadata.prg_rom_size}" - f" | CHR {metadata.chr_rom_size}]" - ), - fg="bright_black", - ) - ) - - for failure in data.failures: - rows.append( - click.style(f"{failure.filepath}", fg="bright_red") - + click.style(f"\n {failure.error}", fg="red") - ) - - rows.append( - click.style( - ( - f"ROM matches: {len(data.matches)}" - f", Errors: {len(data.failures)}" - ), - fg="bright_white", - ) - ) - - return "\n".join(rows) +import functools +from typing import overload + +import click + +from romq.app import nes as nes_app + +type Renderable = nes_app.RomInfo | nes_app.SearchResult | Exception + + +@overload +def render(data: nes_app.RomInfo) -> str: ... + + +@overload +def render(data: nes_app.SearchResult) -> str: ... + + +@overload +def render(data: Exception) -> str: ... + + +def render(data: Renderable) -> str: + return _render(data) + + +@functools.singledispatch +def _render(data: Renderable) -> str: + raise NotImplementedError(f"No renderer for {type(data)}") + + +@_render.register +def _render_error(data: Exception) -> str: # pyright: ignore[reportUnusedFunction] + return click.style(f"{data}", fg="bright_red") + + +@_render.register +def _render_rom_info(data: nes_app.RomInfo) -> str: # pyright: ignore[reportUnusedFunction] + rows: list[str] = [click.style(f"ROM: {data.filepath}", fg="bright_cyan")] + + metadata = data.metadata + raw_rows = ( + ("Format", metadata.rom_format.describe()), + ("Console", metadata.console_type.describe()), + ("TV System", metadata.tv_system.describe()), + ("Mapper", str(metadata.mapper_id)), + ("Submapper", str(metadata.submapper_id)), + ("Mirroring", metadata.mirroring.describe()), + ("Trainer", str(metadata.has_trainer)), + ("Alternate NT", str(metadata.has_alternate_nt)), + ("Battery-backed", str(metadata.has_battery)), + ("PRG ROM", str(metadata.prg_rom_size)), + ("CHR ROM", str(metadata.chr_rom_size)), + ("PRG RAM", str(metadata.prg_ram_size)), + ("PRG NVRAM", str(metadata.prg_nvram_size)), + ("CHR RAM", str(metadata.chr_ram_size)), + ("CHR NVRAM", str(metadata.chr_nvram_size)), + ) + + max_label_len = max(len(label) for label, _ in raw_rows) + label_width = max_label_len + 2 + + for label, value in raw_rows: + styled_label = click.style( + f" {label:<{label_width}}", + fg="bright_black", + ) + styled_value = click.style( + f"{value}", + fg="bright_white", + ) + rows.append(" ".join((styled_label, styled_value))) + + return "\n".join(rows) + + +@_render.register +def _render_search_result(data: nes_app.SearchResult) -> str: # pyright: ignore[reportUnusedFunction] + rows: list[str] = [] + + for match in data.matches: + rows.append(click.style(str(match.filepath), fg="bright_green")) + + metadata = match.metadata + rows.append( + click.style( + ( + f" [{metadata.rom_format.describe()}" + f" | {metadata.tv_system.describe()}" + f" | Mapper {metadata.mapper_id}" + f" | {metadata.mirroring.describe()}" + f" | PRG {metadata.prg_rom_size}" + f" | CHR {metadata.chr_rom_size}]" + ), + fg="bright_black", + ) + ) + + for failure in data.failures: + rows.append( + click.style(f"{failure.filepath}", fg="bright_red") + + click.style(f"\n {failure.error}", fg="red") + ) + + rows.append( + click.style( + ( + f"ROM matches: {len(data.matches)}" + f", Errors: {len(data.failures)}" + ), + fg="bright_white", + ) + ) + + return "\n".join(rows) diff --git a/src/romq/cli/statuses.py b/src/romq/cli/statuses.py index 71d6a55..54194ee 100644 --- a/src/romq/cli/statuses.py +++ b/src/romq/cli/statuses.py @@ -1,4 +1,4 @@ -ES_SUCCESS = 0 -ES_ERROR = 1 -ES_USAGE_ERROR = 2 -ES_INTERNAL_ERROR = 3 +ES_SUCCESS = 0 +ES_ERROR = 1 +ES_USAGE_ERROR = 2 +ES_INTERNAL_ERROR = 3 diff --git a/src/romq/core/__init__.py b/src/romq/core/__init__.py index 8503352..4a78b0d 100644 --- a/src/romq/core/__init__.py +++ b/src/romq/core/__init__.py @@ -1,3 +1,3 @@ -from romq.core import errors, nes - -__all__ = ("errors", "nes") +from romq.core import errors, nes + +__all__ = ("errors", "nes") diff --git a/src/romq/core/errors.py b/src/romq/core/errors.py index 83ef155..d4c7bea 100644 --- a/src/romq/core/errors.py +++ b/src/romq/core/errors.py @@ -1,2 +1,2 @@ -class RomqCoreError(Exception): - """Base exception for all core library errors.""" +class RomqCoreError(Exception): + """Base exception for all core library errors.""" diff --git a/src/romq/core/nes/__init__.py b/src/romq/core/nes/__init__.py index aeece25..a96a81b 100644 --- a/src/romq/core/nes/__init__.py +++ b/src/romq/core/nes/__init__.py @@ -1,3 +1,3 @@ -from romq.core.nes import errors, parser - -__all__ = ("errors", "parser") +from romq.core.nes import errors, parser + +__all__ = ("errors", "parser") diff --git a/src/romq/core/nes/errors.py b/src/romq/core/nes/errors.py index 1f51e62..2584836 100644 --- a/src/romq/core/nes/errors.py +++ b/src/romq/core/nes/errors.py @@ -1,5 +1,5 @@ -from romq.core.errors import RomqCoreError - - -class NesError(RomqCoreError): - """Base exception for all NES-related errors.""" +from romq.core.errors import RomqCoreError + + +class NesError(RomqCoreError): + """Base exception for all NES-related errors.""" diff --git a/src/romq/core/nes/parser/__init__.py b/src/romq/core/nes/parser/__init__.py index d634096..cb491e5 100644 --- a/src/romq/core/nes/parser/__init__.py +++ b/src/romq/core/nes/parser/__init__.py @@ -1,20 +1,20 @@ -from romq.core.nes.parser import errors -from romq.core.nes.parser.api import is_rom, parse_stream -from romq.core.nes.parser.models import ( - ConsoleType, - Mirroring, - RomFormat, - RomMetadata, - TvSystem, -) - -__all__ = ( - "errors", - "parse_stream", - "is_rom", - "ConsoleType", - "Mirroring", - "RomFormat", - "RomMetadata", - "TvSystem", -) +from romq.core.nes.parser import errors +from romq.core.nes.parser.api import is_rom, parse_stream +from romq.core.nes.parser.models import ( + ConsoleType, + Mirroring, + RomFormat, + RomMetadata, + TvSystem, +) + +__all__ = ( + "errors", + "parse_stream", + "is_rom", + "ConsoleType", + "Mirroring", + "RomFormat", + "RomMetadata", + "TvSystem", +) diff --git a/src/romq/core/nes/parser/api.py b/src/romq/core/nes/parser/api.py index 9c3b5a0..5addacf 100644 --- a/src/romq/core/nes/parser/api.py +++ b/src/romq/core/nes/parser/api.py @@ -1,173 +1,173 @@ -import contextlib -import struct -from collections.abc import Generator -from typing import assert_never - -from romq.core.nes.parser.common import Flags6, Flags7, RawHeader -from romq.core.nes.parser.errors import ( - HeaderParseError, - InvalidHeaderError, - UnknownRomError, - UnsupportedRomError, -) -from romq.core.nes.parser.ines import parse as parse_ines -from romq.core.nes.parser.ines20 import parse as parse_ines20 -from romq.core.nes.parser.models import ( - ConsoleType, - Mirroring, - RomFormat, - RomMetadata, -) -from romq.core.nes.parser.types import ( - ReadableSeekableStream, - ReadableStream, - SeekableStream, -) - -_HEADER_SIZE = 16 -_HEADER_ID = b"NES\x1a" -_HEADER_ID_SIZE = len(_HEADER_ID) -_HEADER_STRUCT_SPEC = struct.Struct( - f">{_HEADER_ID_SIZE}s{_HEADER_SIZE - _HEADER_ID_SIZE}B" -) - - -def is_rom(stream: ReadableSeekableStream) -> bool: - """Check whether a stream contains a valid NES ROM. - - Validates only the iNES header for structural correctness. It does - not verify internal ROM parameters (e.g., PRG or CHR sizes) or - data integrity. - - The stream position is restored to its original location after the check. - - Args: - stream: A readable, seekable byte stream positioned at the - start of a potential ROM file. - - Returns: - ``True`` if the stream contains a valid iNES header, ``False`` - otherwise. - - """ - with _restore_stream_position(stream): - try: - _ = _parse_raw_header(stream) - except HeaderParseError: - return False - return True - - -def parse_stream(stream: ReadableSeekableStream) -> RomMetadata: - """Parse a NES ROM header and return its metadata. - - Extracts and validates metadata from the iNES header. This operation - is header-only and does not read the entire ROM file or verify - internal data integrity. - Supports iNES 1.0 and 2.0 formats. Archaic iNES is not supported. - - The stream position is restored to its original location after the check. - - Args: - stream: A readable, seekable byte stream positioned at the - start of a NES ROM file. - - Returns: - A :class:`~romq.core.nes.parser.models.RomMetadata` instance - with the ROM metadata. - - Raises: - :exc:`~romq.core.nes.parser.errors.HeaderParseError`: If the header - is missing or malformed. - :exc:`~romq.core.nes.parser.errors.InvalidHeaderError`: If the magic - bytes are invalid. - :exc:`~romq.core.nes.parser.errors.UnsupportedRomError`: If the ROM - uses an unsupported format. - :exc:`~romq.core.nes.parser.errors.UnknownRomError`: If the ROM - format is unrecognized. - - """ - with _restore_stream_position(stream): - raw_header = _parse_raw_header(stream) - - flags6 = _parse_flags6(raw_header.flags6) - flags7 = _parse_flags7(raw_header.flags7) - - match flags7.rom_format: - case RomFormat.INES: - rom_info = parse_ines(raw_header, flags6, flags7) - case RomFormat.INES_20: - rom_info = parse_ines20(raw_header, flags6, flags7) - case RomFormat.ARCHAIC_INES: - raise UnsupportedRomError( - "Archaic iNES format is not supported" - ) - case _: - assert_never(flags7.rom_format) - - return rom_info - - -@contextlib.contextmanager -def _restore_stream_position( - stream: SeekableStream, -) -> Generator[None]: - orig_pos = stream.tell() - try: - yield - finally: - _ = stream.seek(orig_pos) - - -def _parse_raw_header(stream: ReadableStream) -> RawHeader: - header_bytes = stream.read(_HEADER_SIZE) - if len(header_bytes) != _HEADER_SIZE: - raise HeaderParseError("ROM header is too small") - - try: - header = RawHeader._make(_HEADER_STRUCT_SPEC.unpack(header_bytes)) - except struct.error as error: - raise HeaderParseError("Invalid header structure") from error - - if header.id != _HEADER_ID: - raise InvalidHeaderError("Invalid iNES header ID") - - return header - - -def _parse_flags6(flags: int) -> Flags6: - return Flags6( - mirroring=Mirroring.VERTICAL if flags & 1 else Mirroring.HORIZONTAL, - has_battery=bool(flags & 2), - has_trainer=bool(flags & 4), - has_alternate_nt=bool(flags & 8), - mapper_id_lsb=(flags & 0xF0) >> 4, - ) - - -def _parse_flags7(flags: int) -> Flags7: - match flags & 3: - case 1: - console_type = ConsoleType.VS_UNISYSTEM - case 2: - console_type = ConsoleType.PLAYCHOICE_10 - case _: - console_type = ConsoleType.NES - - match (flags & 0x0C) >> 2: - case 0: - rom_format = RomFormat.INES - case 1: - rom_format = RomFormat.ARCHAIC_INES - case 2: - rom_format = RomFormat.INES_20 - case format_bits: - raise UnknownRomError( - f"Unknown ROM format bits: 0x{format_bits:02X}" - ) - - return Flags7( - console_type=console_type, - rom_format=rom_format, - mapper_id_msb=(flags & 0xF0) >> 4, - ) +import contextlib +import struct +from collections.abc import Generator +from typing import assert_never + +from romq.core.nes.parser.common import Flags6, Flags7, RawHeader +from romq.core.nes.parser.errors import ( + HeaderParseError, + InvalidHeaderError, + UnknownRomError, + UnsupportedRomError, +) +from romq.core.nes.parser.ines import parse as parse_ines +from romq.core.nes.parser.ines20 import parse as parse_ines20 +from romq.core.nes.parser.models import ( + ConsoleType, + Mirroring, + RomFormat, + RomMetadata, +) +from romq.core.nes.parser.types import ( + ReadableSeekableStream, + ReadableStream, + SeekableStream, +) + +_HEADER_SIZE = 16 +_HEADER_ID = b"NES\x1a" +_HEADER_ID_SIZE = len(_HEADER_ID) +_HEADER_STRUCT_SPEC = struct.Struct( + f">{_HEADER_ID_SIZE}s{_HEADER_SIZE - _HEADER_ID_SIZE}B" +) + + +def is_rom(stream: ReadableSeekableStream) -> bool: + """Check whether a stream contains a valid NES ROM. + + Validates only the iNES header for structural correctness. It does + not verify internal ROM parameters (e.g., PRG or CHR sizes) or + data integrity. + + The stream position is restored to its original location after the check. + + Args: + stream: A readable, seekable byte stream positioned at the + start of a potential ROM file. + + Returns: + ``True`` if the stream contains a valid iNES header, ``False`` + otherwise. + + """ + with _restore_stream_position(stream): + try: + _ = _parse_raw_header(stream) + except HeaderParseError: + return False + return True + + +def parse_stream(stream: ReadableSeekableStream) -> RomMetadata: + """Parse a NES ROM header and return its metadata. + + Extracts and validates metadata from the iNES header. This operation + is header-only and does not read the entire ROM file or verify + internal data integrity. + Supports iNES 1.0 and 2.0 formats. Archaic iNES is not supported. + + The stream position is restored to its original location after the check. + + Args: + stream: A readable, seekable byte stream positioned at the + start of a NES ROM file. + + Returns: + A :class:`~romq.core.nes.parser.models.RomMetadata` instance + with the ROM metadata. + + Raises: + :exc:`~romq.core.nes.parser.errors.HeaderParseError`: If the header + is missing or malformed. + :exc:`~romq.core.nes.parser.errors.InvalidHeaderError`: If the magic + bytes are invalid. + :exc:`~romq.core.nes.parser.errors.UnsupportedRomError`: If the ROM + uses an unsupported format. + :exc:`~romq.core.nes.parser.errors.UnknownRomError`: If the ROM + format is unrecognized. + + """ + with _restore_stream_position(stream): + raw_header = _parse_raw_header(stream) + + flags6 = _parse_flags6(raw_header.flags6) + flags7 = _parse_flags7(raw_header.flags7) + + match flags7.rom_format: + case RomFormat.INES: + rom_info = parse_ines(raw_header, flags6, flags7) + case RomFormat.INES_20: + rom_info = parse_ines20(raw_header, flags6, flags7) + case RomFormat.ARCHAIC_INES: + raise UnsupportedRomError( + "Archaic iNES format is not supported" + ) + case _: + assert_never(flags7.rom_format) + + return rom_info + + +@contextlib.contextmanager +def _restore_stream_position( + stream: SeekableStream, +) -> Generator[None]: + orig_pos = stream.tell() + try: + yield + finally: + _ = stream.seek(orig_pos) + + +def _parse_raw_header(stream: ReadableStream) -> RawHeader: + header_bytes = stream.read(_HEADER_SIZE) + if len(header_bytes) != _HEADER_SIZE: + raise HeaderParseError("ROM header is too small") + + try: + header = RawHeader._make(_HEADER_STRUCT_SPEC.unpack(header_bytes)) + except struct.error as error: + raise HeaderParseError("Invalid header structure") from error + + if header.id != _HEADER_ID: + raise InvalidHeaderError("Invalid iNES header ID") + + return header + + +def _parse_flags6(flags: int) -> Flags6: + return Flags6( + mirroring=Mirroring.VERTICAL if flags & 1 else Mirroring.HORIZONTAL, + has_battery=bool(flags & 2), + has_trainer=bool(flags & 4), + has_alternate_nt=bool(flags & 8), + mapper_id_lsb=(flags & 0xF0) >> 4, + ) + + +def _parse_flags7(flags: int) -> Flags7: + match flags & 3: + case 1: + console_type = ConsoleType.VS_UNISYSTEM + case 2: + console_type = ConsoleType.PLAYCHOICE_10 + case _: + console_type = ConsoleType.NES + + match (flags & 0x0C) >> 2: + case 0: + rom_format = RomFormat.INES + case 1: + rom_format = RomFormat.ARCHAIC_INES + case 2: + rom_format = RomFormat.INES_20 + case format_bits: + raise UnknownRomError( + f"Unknown ROM format bits: 0x{format_bits:02X}" + ) + + return Flags7( + console_type=console_type, + rom_format=rom_format, + mapper_id_msb=(flags & 0xF0) >> 4, + ) diff --git a/src/romq/core/nes/parser/common.py b/src/romq/core/nes/parser/common.py index 59ed3ad..ce520d6 100644 --- a/src/romq/core/nes/parser/common.py +++ b/src/romq/core/nes/parser/common.py @@ -1,39 +1,39 @@ -from typing import NamedTuple - -from romq.core.nes.parser.models import ConsoleType, Mirroring, RomFormat - -_KB = 1024 -PRG_BANK_SIZE = 16 * _KB -CHR_BANK_SIZE = 8 * _KB -PRG_RAM_BANK_SIZE = 8 * _KB -CHR_RAM_BANK_SIZE = 8 * _KB - - -class Flags6(NamedTuple): - mirroring: Mirroring - has_battery: bool - has_trainer: bool - has_alternate_nt: bool - mapper_id_lsb: int - - -class Flags7(NamedTuple): - console_type: ConsoleType - rom_format: RomFormat - mapper_id_msb: int - - -class RawHeader(NamedTuple): - id: bytes - prg_rom_banks_lsb: int - chr_rom_banks_lsb: int - flags6: int - flags7: int - flags8: int - flags9: int - flags10: int - flags11: int - flags12: int - flags13: int - flags14: int - flags15: int +from typing import NamedTuple + +from romq.core.nes.parser.models import ConsoleType, Mirroring, RomFormat + +_KB = 1024 +PRG_BANK_SIZE = 16 * _KB +CHR_BANK_SIZE = 8 * _KB +PRG_RAM_BANK_SIZE = 8 * _KB +CHR_RAM_BANK_SIZE = 8 * _KB + + +class Flags6(NamedTuple): + mirroring: Mirroring + has_battery: bool + has_trainer: bool + has_alternate_nt: bool + mapper_id_lsb: int + + +class Flags7(NamedTuple): + console_type: ConsoleType + rom_format: RomFormat + mapper_id_msb: int + + +class RawHeader(NamedTuple): + id: bytes + prg_rom_banks_lsb: int + chr_rom_banks_lsb: int + flags6: int + flags7: int + flags8: int + flags9: int + flags10: int + flags11: int + flags12: int + flags13: int + flags14: int + flags15: int diff --git a/src/romq/core/nes/parser/errors.py b/src/romq/core/nes/parser/errors.py index e22181d..a02ceb5 100644 --- a/src/romq/core/nes/parser/errors.py +++ b/src/romq/core/nes/parser/errors.py @@ -1,21 +1,21 @@ -from romq.core.nes.errors import NesError - - -class ParseError(NesError): - """Base exception for ROM parsing errors.""" - - -class HeaderParseError(ParseError): - """Raised when the NES header cannot be read or decoded.""" - - -class InvalidHeaderError(HeaderParseError): - """Raised when the NES magic bytes are invalid.""" - - -class UnknownRomError(ParseError): - """Raised when a ROM format is not recognized.""" - - -class UnsupportedRomError(ParseError): - """Raised when a ROM format is identified but not supported.""" +from romq.core.nes.errors import NesError + + +class ParseError(NesError): + """Base exception for ROM parsing errors.""" + + +class HeaderParseError(ParseError): + """Raised when the NES header cannot be read or decoded.""" + + +class InvalidHeaderError(HeaderParseError): + """Raised when the NES magic bytes are invalid.""" + + +class UnknownRomError(ParseError): + """Raised when a ROM format is not recognized.""" + + +class UnsupportedRomError(ParseError): + """Raised when a ROM format is identified but not supported.""" diff --git a/src/romq/core/nes/parser/ines.py b/src/romq/core/nes/parser/ines.py index 9b56155..95fac6d 100644 --- a/src/romq/core/nes/parser/ines.py +++ b/src/romq/core/nes/parser/ines.py @@ -1,49 +1,49 @@ -from typing import NamedTuple - -from romq.core.nes.parser.common import ( - CHR_BANK_SIZE, - PRG_BANK_SIZE, - PRG_RAM_BANK_SIZE, - Flags6, - Flags7, - RawHeader, -) -from romq.core.nes.parser.models import RomMetadata, TvSystem - - -def parse( - raw_header: RawHeader, - flags6: Flags6, - flags7: Flags7, -) -> RomMetadata: - flags9 = _parse_flags9(raw_header.flags9) - - return RomMetadata( - prg_rom_size=raw_header.prg_rom_banks_lsb * PRG_BANK_SIZE, - chr_rom_size=raw_header.chr_rom_banks_lsb * CHR_BANK_SIZE, - prg_ram_size=raw_header.flags8 * PRG_RAM_BANK_SIZE, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=flags7.mapper_id_msb << 4 | flags6.mapper_id_lsb, - submapper_id=0, - rom_format=flags7.rom_format, - mirroring=flags6.mirroring, - console_type=flags7.console_type, - tv_system=flags9.tv_system, - has_battery=flags6.has_battery, - has_trainer=flags6.has_trainer, - has_alternate_nt=flags6.has_alternate_nt, - ) - - -class _Flags9(NamedTuple): - tv_system: TvSystem - reserved_bits: int - - -def _parse_flags9(flags: int) -> _Flags9: - return _Flags9( - tv_system=TvSystem.PAL if flags & 1 else TvSystem.NTSC, - reserved_bits=flags & 0xFE, - ) +from typing import NamedTuple + +from romq.core.nes.parser.common import ( + CHR_BANK_SIZE, + PRG_BANK_SIZE, + PRG_RAM_BANK_SIZE, + Flags6, + Flags7, + RawHeader, +) +from romq.core.nes.parser.models import RomMetadata, TvSystem + + +def parse( + raw_header: RawHeader, + flags6: Flags6, + flags7: Flags7, +) -> RomMetadata: + flags9 = _parse_flags9(raw_header.flags9) + + return RomMetadata( + prg_rom_size=raw_header.prg_rom_banks_lsb * PRG_BANK_SIZE, + chr_rom_size=raw_header.chr_rom_banks_lsb * CHR_BANK_SIZE, + prg_ram_size=raw_header.flags8 * PRG_RAM_BANK_SIZE, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=flags7.mapper_id_msb << 4 | flags6.mapper_id_lsb, + submapper_id=0, + rom_format=flags7.rom_format, + mirroring=flags6.mirroring, + console_type=flags7.console_type, + tv_system=flags9.tv_system, + has_battery=flags6.has_battery, + has_trainer=flags6.has_trainer, + has_alternate_nt=flags6.has_alternate_nt, + ) + + +class _Flags9(NamedTuple): + tv_system: TvSystem + reserved_bits: int + + +def _parse_flags9(flags: int) -> _Flags9: + return _Flags9( + tv_system=TvSystem.PAL if flags & 1 else TvSystem.NTSC, + reserved_bits=flags & 0xFE, + ) diff --git a/src/romq/core/nes/parser/ines20.py b/src/romq/core/nes/parser/ines20.py index dc4e120..27f3c33 100644 --- a/src/romq/core/nes/parser/ines20.py +++ b/src/romq/core/nes/parser/ines20.py @@ -1,153 +1,153 @@ -from typing import NamedTuple - -from romq.core.nes.parser.common import ( - CHR_BANK_SIZE, - PRG_BANK_SIZE, - Flags6, - Flags7, - RawHeader, -) -from romq.core.nes.parser.errors import ParseError -from romq.core.nes.parser.models import RomMetadata, TvSystem - -_EXPONENT_SIZE_FLAGS = 0x0F - - -def parse( - raw_header: RawHeader, - flags6: Flags6, - flags7: Flags7, -) -> RomMetadata: - flags8 = _parse_flags8(raw_header.flags8) - flags9 = _parse_flags9(raw_header.flags9) - flags10 = _parse_flags10(raw_header.flags10) - flags11 = _parse_flags11(raw_header.flags11) - flags12 = _parse_flags12(raw_header.flags12) - # TODO: also parse 13-15 - - mapper_id = ( - flags8.mapper_id_msb << 8 - | flags7.mapper_id_msb << 4 - | flags6.mapper_id_lsb - ) - - prg_rom_size = _calculate_rom_size( - raw_header.prg_rom_banks_lsb, flags9.prg_rom_banks_msb, PRG_BANK_SIZE - ) - chr_rom_size = _calculate_rom_size( - raw_header.chr_rom_banks_lsb, flags9.chr_rom_banks_msb, CHR_BANK_SIZE - ) - - prg_ram_size = _calculate_ram_size(flags10.prg_ram_shift) - prg_nvram_size = _calculate_ram_size(flags10.prg_nvram_shift) - - chr_ram_size = _calculate_ram_size(flags11.chr_ram_shift) - chr_nvram_size = _calculate_ram_size(flags11.chr_nvram_shift) - - return RomMetadata( - prg_rom_size=prg_rom_size, - chr_rom_size=chr_rom_size, - prg_ram_size=prg_ram_size, - prg_nvram_size=prg_nvram_size, - chr_ram_size=chr_ram_size, - chr_nvram_size=chr_nvram_size, - mapper_id=mapper_id, - submapper_id=flags8.submapper_id, - rom_format=flags7.rom_format, - mirroring=flags6.mirroring, - console_type=flags7.console_type, - tv_system=flags12.tv_system, - has_battery=flags6.has_battery, - has_trainer=flags6.has_trainer, - has_alternate_nt=flags6.has_alternate_nt, - ) - - -class _Flags8(NamedTuple): - mapper_id_msb: int - submapper_id: int - - -class _Flags9(NamedTuple): - prg_rom_banks_msb: int - chr_rom_banks_msb: int - - -class _Flags10(NamedTuple): - prg_ram_shift: int - prg_nvram_shift: int - - -class _Flags11(NamedTuple): - chr_ram_shift: int - chr_nvram_shift: int - - -class _Flags12(NamedTuple): - tv_system: TvSystem - - -def _parse_flags8(flags: int) -> _Flags8: - return _Flags8( - mapper_id_msb=flags & 0x0F, submapper_id=(flags & 0xF0) >> 4 - ) - - -def _parse_flags9(flags: int) -> _Flags9: - return _Flags9( - prg_rom_banks_msb=flags & 0x0F, chr_rom_banks_msb=(flags & 0xF0) >> 4 - ) - - -def _parse_flags10(flags: int) -> _Flags10: - return _Flags10( - prg_ram_shift=flags & 0x0F, prg_nvram_shift=(flags & 0xF0) >> 4 - ) - - -def _parse_flags11(flags: int) -> _Flags11: - return _Flags11( - chr_ram_shift=flags & 0x0F, chr_nvram_shift=(flags & 0xF0) >> 4 - ) - - -def _parse_flags12(flags: int) -> _Flags12: - match flags & 3: - case 0: - tv_system = TvSystem.NTSC - case 1: - tv_system = TvSystem.PAL - case 2: - tv_system = TvSystem.MULTIPLE_REGION - case 3: - tv_system = TvSystem.DENDY - case _: - # Impossible case - raise ParseError("Unknown TV system bits") - - return _Flags12(tv_system=tv_system) - - -def _calculate_rom_size( - rom_size_lsb: int, - rom_size_msb: int, - bank_size: int, -) -> int: - if rom_size_msb == _EXPONENT_SIZE_FLAGS: - """ - ++++----------- Header byte 9 D3..D0 - |||| ++++-++++- Header byte 4 - D~BA98 7654 3210 - -------------- - 1111 EEEE EEMM - |||| ||++- Multiplier, actual value is MM*2+1 (1,3,5,7) - ++++-++--- Exponent (2^E), 0-63 - - The actual PRG-ROM size is 2^E *(MM*2+1) bytes. - """ - return (1 << (rom_size_lsb >> 2)) * (((rom_size_lsb & 3) * 2) + 1) - return (rom_size_msb << 8 | rom_size_lsb) * bank_size - - -def _calculate_ram_size(shift_bits: int) -> int: - return 64 << shift_bits if shift_bits else 0 +from typing import NamedTuple + +from romq.core.nes.parser.common import ( + CHR_BANK_SIZE, + PRG_BANK_SIZE, + Flags6, + Flags7, + RawHeader, +) +from romq.core.nes.parser.errors import ParseError +from romq.core.nes.parser.models import RomMetadata, TvSystem + +_EXPONENT_SIZE_FLAGS = 0x0F + + +def parse( + raw_header: RawHeader, + flags6: Flags6, + flags7: Flags7, +) -> RomMetadata: + flags8 = _parse_flags8(raw_header.flags8) + flags9 = _parse_flags9(raw_header.flags9) + flags10 = _parse_flags10(raw_header.flags10) + flags11 = _parse_flags11(raw_header.flags11) + flags12 = _parse_flags12(raw_header.flags12) + # TODO: also parse 13-15 + + mapper_id = ( + flags8.mapper_id_msb << 8 + | flags7.mapper_id_msb << 4 + | flags6.mapper_id_lsb + ) + + prg_rom_size = _calculate_rom_size( + raw_header.prg_rom_banks_lsb, flags9.prg_rom_banks_msb, PRG_BANK_SIZE + ) + chr_rom_size = _calculate_rom_size( + raw_header.chr_rom_banks_lsb, flags9.chr_rom_banks_msb, CHR_BANK_SIZE + ) + + prg_ram_size = _calculate_ram_size(flags10.prg_ram_shift) + prg_nvram_size = _calculate_ram_size(flags10.prg_nvram_shift) + + chr_ram_size = _calculate_ram_size(flags11.chr_ram_shift) + chr_nvram_size = _calculate_ram_size(flags11.chr_nvram_shift) + + return RomMetadata( + prg_rom_size=prg_rom_size, + chr_rom_size=chr_rom_size, + prg_ram_size=prg_ram_size, + prg_nvram_size=prg_nvram_size, + chr_ram_size=chr_ram_size, + chr_nvram_size=chr_nvram_size, + mapper_id=mapper_id, + submapper_id=flags8.submapper_id, + rom_format=flags7.rom_format, + mirroring=flags6.mirroring, + console_type=flags7.console_type, + tv_system=flags12.tv_system, + has_battery=flags6.has_battery, + has_trainer=flags6.has_trainer, + has_alternate_nt=flags6.has_alternate_nt, + ) + + +class _Flags8(NamedTuple): + mapper_id_msb: int + submapper_id: int + + +class _Flags9(NamedTuple): + prg_rom_banks_msb: int + chr_rom_banks_msb: int + + +class _Flags10(NamedTuple): + prg_ram_shift: int + prg_nvram_shift: int + + +class _Flags11(NamedTuple): + chr_ram_shift: int + chr_nvram_shift: int + + +class _Flags12(NamedTuple): + tv_system: TvSystem + + +def _parse_flags8(flags: int) -> _Flags8: + return _Flags8( + mapper_id_msb=flags & 0x0F, submapper_id=(flags & 0xF0) >> 4 + ) + + +def _parse_flags9(flags: int) -> _Flags9: + return _Flags9( + prg_rom_banks_msb=flags & 0x0F, chr_rom_banks_msb=(flags & 0xF0) >> 4 + ) + + +def _parse_flags10(flags: int) -> _Flags10: + return _Flags10( + prg_ram_shift=flags & 0x0F, prg_nvram_shift=(flags & 0xF0) >> 4 + ) + + +def _parse_flags11(flags: int) -> _Flags11: + return _Flags11( + chr_ram_shift=flags & 0x0F, chr_nvram_shift=(flags & 0xF0) >> 4 + ) + + +def _parse_flags12(flags: int) -> _Flags12: + match flags & 3: + case 0: + tv_system = TvSystem.NTSC + case 1: + tv_system = TvSystem.PAL + case 2: + tv_system = TvSystem.MULTIPLE_REGION + case 3: + tv_system = TvSystem.DENDY + case _: + # Impossible case + raise ParseError("Unknown TV system bits") + + return _Flags12(tv_system=tv_system) + + +def _calculate_rom_size( + rom_size_lsb: int, + rom_size_msb: int, + bank_size: int, +) -> int: + if rom_size_msb == _EXPONENT_SIZE_FLAGS: + """ + ++++----------- Header byte 9 D3..D0 + |||| ++++-++++- Header byte 4 + D~BA98 7654 3210 + -------------- + 1111 EEEE EEMM + |||| ||++- Multiplier, actual value is MM*2+1 (1,3,5,7) + ++++-++--- Exponent (2^E), 0-63 + + The actual PRG-ROM size is 2^E *(MM*2+1) bytes. + """ + return (1 << (rom_size_lsb >> 2)) * (((rom_size_lsb & 3) * 2) + 1) + return (rom_size_msb << 8 | rom_size_lsb) * bank_size + + +def _calculate_ram_size(shift_bits: int) -> int: + return 64 << shift_bits if shift_bits else 0 diff --git a/src/romq/core/nes/parser/models.py b/src/romq/core/nes/parser/models.py index f60f50e..6efc9f8 100644 --- a/src/romq/core/nes/parser/models.py +++ b/src/romq/core/nes/parser/models.py @@ -1,133 +1,133 @@ -import enum -from dataclasses import dataclass -from typing import assert_never - - -class RomFormat(enum.Enum): - """Rom format type.""" - - INES = "INES" - INES_20 = "INES_20" - ARCHAIC_INES = "ARCHAIC_INES" - - def describe(self) -> str: - match self: - case RomFormat.INES: - return "iNES" - case RomFormat.INES_20: - return "iNES 2.0" - case RomFormat.ARCHAIC_INES: - return "Archaic iNES" - case _: - assert_never(self) - - -class Mirroring(enum.Enum): - """Screen mirroring type.""" - - VERTICAL = "VERTICAL" - HORIZONTAL = "HORIZONTAL" - - def describe(self) -> str: - match self: - case Mirroring.VERTICAL: - return "Vertical" - case Mirroring.HORIZONTAL: - return "Horizontal" - case _: - assert_never(self) - - -class ConsoleType(enum.Enum): - """Target console platform.""" - - NES = "NES" - VS_UNISYSTEM = "VS_UNISYSTEM" - PLAYCHOICE_10 = "PLAYCHOICE_10" - - def describe(self) -> str: - match self: - case ConsoleType.NES: - return "NES" - case ConsoleType.VS_UNISYSTEM: - return "VS Unisystem" - case ConsoleType.PLAYCHOICE_10: - return "PlayChoice-10" - case _: - assert_never(self) - - -class TvSystem(enum.Enum): - """TV system / region variant.""" - - NTSC = "NTSC" - PAL = "PAL" - DENDY = "DENDY" - MULTIPLE_REGION = "MULTIPLE_REGION" - - def describe(self) -> str: - match self: - case TvSystem.NTSC: - return "RP2C02 (NTSC NES)" - case TvSystem.PAL: - return "RP2C07 (PAL NES)" - case TvSystem.DENDY: - return "UA6538 (Dendy)" - case TvSystem.MULTIPLE_REGION: - return "Multiple-region" - case _: - assert_never(self) - - -@dataclass(slots=True, frozen=True) -class RomMetadata: - """Metadata about a NES ROM. - - Attributes: - prg_rom_size: - Size of PRG-ROM in bytes. - chr_rom_size: - Size of CHR-ROM in bytes (0 if CHR-RAM is used). - prg_ram_size: - Size of PRG-RAM in bytes (0 if none). - prg_nvram_size: - Size of onboard NVRAM in bytes (0 if none). - chr_ram_size: - Size of CHR-RAM in bytes (0 if CHR-ROM is used). - chr_nvram_size: - Size of CHR NVRAM in bytes (reserved). - mapper_id: - Mapper number. Identifies the CPU/PPU mapper chip. - submapper_id: - Submapper number. Used by some mappers for variants. - rom_format: - The iNES variant. - mirroring: - Screen mirroring type. - console_type: - Target platform. - tv_system: - TV system / region variant. - has_battery: - Whether the ROM uses battery-backed RAM for saves. - has_trainer: - Whether the ROM contains a trainer program after the header. - has_alternate_nt: - Whether the ROM uses alternate nametable mirroring. - """ - - prg_rom_size: int - chr_rom_size: int - prg_ram_size: int - prg_nvram_size: int - chr_ram_size: int - chr_nvram_size: int - mapper_id: int - submapper_id: int - rom_format: RomFormat - mirroring: Mirroring - console_type: ConsoleType - tv_system: TvSystem - has_battery: bool - has_trainer: bool - has_alternate_nt: bool +import enum +from dataclasses import dataclass +from typing import assert_never + + +class RomFormat(enum.Enum): + """Rom format type.""" + + INES = "INES" + INES_20 = "INES_20" + ARCHAIC_INES = "ARCHAIC_INES" + + def describe(self) -> str: + match self: + case RomFormat.INES: + return "iNES" + case RomFormat.INES_20: + return "iNES 2.0" + case RomFormat.ARCHAIC_INES: + return "Archaic iNES" + case _: + assert_never(self) + + +class Mirroring(enum.Enum): + """Screen mirroring type.""" + + VERTICAL = "VERTICAL" + HORIZONTAL = "HORIZONTAL" + + def describe(self) -> str: + match self: + case Mirroring.VERTICAL: + return "Vertical" + case Mirroring.HORIZONTAL: + return "Horizontal" + case _: + assert_never(self) + + +class ConsoleType(enum.Enum): + """Target console platform.""" + + NES = "NES" + VS_UNISYSTEM = "VS_UNISYSTEM" + PLAYCHOICE_10 = "PLAYCHOICE_10" + + def describe(self) -> str: + match self: + case ConsoleType.NES: + return "NES" + case ConsoleType.VS_UNISYSTEM: + return "VS Unisystem" + case ConsoleType.PLAYCHOICE_10: + return "PlayChoice-10" + case _: + assert_never(self) + + +class TvSystem(enum.Enum): + """TV system / region variant.""" + + NTSC = "NTSC" + PAL = "PAL" + DENDY = "DENDY" + MULTIPLE_REGION = "MULTIPLE_REGION" + + def describe(self) -> str: + match self: + case TvSystem.NTSC: + return "RP2C02 (NTSC NES)" + case TvSystem.PAL: + return "RP2C07 (PAL NES)" + case TvSystem.DENDY: + return "UA6538 (Dendy)" + case TvSystem.MULTIPLE_REGION: + return "Multiple-region" + case _: + assert_never(self) + + +@dataclass(slots=True, frozen=True) +class RomMetadata: + """Metadata about a NES ROM. + + Attributes: + prg_rom_size: + Size of PRG-ROM in bytes. + chr_rom_size: + Size of CHR-ROM in bytes (0 if CHR-RAM is used). + prg_ram_size: + Size of PRG-RAM in bytes (0 if none). + prg_nvram_size: + Size of onboard NVRAM in bytes (0 if none). + chr_ram_size: + Size of CHR-RAM in bytes (0 if CHR-ROM is used). + chr_nvram_size: + Size of CHR NVRAM in bytes (reserved). + mapper_id: + Mapper number. Identifies the CPU/PPU mapper chip. + submapper_id: + Submapper number. Used by some mappers for variants. + rom_format: + The iNES variant. + mirroring: + Screen mirroring type. + console_type: + Target platform. + tv_system: + TV system / region variant. + has_battery: + Whether the ROM uses battery-backed RAM for saves. + has_trainer: + Whether the ROM contains a trainer program after the header. + has_alternate_nt: + Whether the ROM uses alternate nametable mirroring. + """ + + prg_rom_size: int + chr_rom_size: int + prg_ram_size: int + prg_nvram_size: int + chr_ram_size: int + chr_nvram_size: int + mapper_id: int + submapper_id: int + rom_format: RomFormat + mirroring: Mirroring + console_type: ConsoleType + tv_system: TvSystem + has_battery: bool + has_trainer: bool + has_alternate_nt: bool diff --git a/src/romq/core/nes/parser/types.py b/src/romq/core/nes/parser/types.py index ae1acf3..5229277 100644 --- a/src/romq/core/nes/parser/types.py +++ b/src/romq/core/nes/parser/types.py @@ -1,14 +1,14 @@ -from typing import Protocol - - -class ReadableStream(Protocol): - def read(self, n: int = -1, /) -> bytes: ... - - -class SeekableStream(Protocol): - def seek(self, offset: int, whence: int = 0, /) -> int: ... - def tell(self) -> int: ... - - -class ReadableSeekableStream(ReadableStream, SeekableStream, Protocol): - pass +from typing import Protocol + + +class ReadableStream(Protocol): + def read(self, n: int = -1, /) -> bytes: ... + + +class SeekableStream(Protocol): + def seek(self, offset: int, whence: int = 0, /) -> int: ... + def tell(self) -> int: ... + + +class ReadableSeekableStream(ReadableStream, SeekableStream, Protocol): + pass diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 2866560..03d7245 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -1,16 +1,16 @@ -import tempfile -from pathlib import Path - -import pytest -from pyfakefs.fake_filesystem import FakeFilesystem - - -@pytest.fixture -def tmp_dir(request: pytest.FixtureRequest, fs: FakeFilesystem) -> Path: - dir_path = Path( - tempfile.gettempdir(), - request.module.__name__, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] - request.function.__name__, # pyright: ignore[reportAny] - ) - _ = fs.create_dir(dir_path) # pyright: ignore[reportUnknownMemberType] - return dir_path +import tempfile +from pathlib import Path + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem + + +@pytest.fixture +def tmp_dir(request: pytest.FixtureRequest, fs: FakeFilesystem) -> Path: + dir_path = Path( + tempfile.gettempdir(), + request.module.__name__, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + request.function.__name__, # pyright: ignore[reportAny] + ) + _ = fs.create_dir(dir_path) # pyright: ignore[reportUnknownMemberType] + return dir_path diff --git a/tests/app/test_nes_inspect.py b/tests/app/test_nes_inspect.py index 60d6dcb..2a25ada 100644 --- a/tests/app/test_nes_inspect.py +++ b/tests/app/test_nes_inspect.py @@ -1,145 +1,145 @@ -import functools -import re -from collections.abc import Callable -from pathlib import Path -from types import ModuleType - -import pytest - -import romq.app.io.file as file_io -from romq.app import errors as app_errors -from romq.app import nes as nes_app - -from tests.app.utils import ( - create_7zipfile, - create_empty_7zipfile, - create_empty_zipfile, - create_file, - create_zipfile, -) - -# pyright: reportUnnecessaryTypeIgnoreComment=false - -_ROM_HEADER = b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00" - - -@pytest.mark.app -@pytest.mark.parametrize( - "romfile_factory", - (create_file, create_zipfile, create_7zipfile), -) -def test_inspect_rom__success( - tmp_dir: Path, - romfile_factory: Callable[[Path, bytes], Path], -) -> None: - rom_path = romfile_factory(tmp_dir, _ROM_HEADER) - expected_rom_info = nes_app.RomInfo( - filepath=rom_path, - metadata=nes_app.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_app.RomFormat.INES_20, - mirroring=nes_app.Mirroring.HORIZONTAL, - console_type=nes_app.ConsoleType.NES, - tv_system=nes_app.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ), - ) - - assert nes_app.inspect_rom(rom_path) == expected_rom_info - - -@pytest.mark.app -def test_inspect_rom__file_not_exist(tmp_dir: Path) -> None: - rom_path = tmp_dir.joinpath("test_inspect_rom__file_not_exist.nes") - with pytest.raises(FileNotFoundError): - _ = nes_app.inspect_rom(rom_path) - - -@pytest.mark.app -@pytest.mark.parametrize( - "header,error_regexp", - ( - ( - b"", - "ROM header is too small", - ), - ( - b"NES\x00\x01\x01\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00", - "Invalid iNES header ID", - ), - ( - b"NES\x1a\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00", - "Archaic iNES format is not supported", - ), - ( - b"NES\x1a\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00", - "Unknown ROM format bits: 0x03", - ), - ), -) -def test_inspect_rom__invalid_rom_header( - tmp_dir: Path, - header: bytes, - error_regexp: str, -) -> None: - rom_path = tmp_dir.joinpath("test_inspect_rom__expected_failures.nes") - _ = rom_path.write_bytes(header) - - with pytest.raises(app_errors.RomInspectError, match=error_regexp): - _ = nes_app.inspect_rom(rom_path) - - -@pytest.mark.app -@pytest.mark.parametrize( - "romfile_factory", - ( - create_empty_zipfile, - create_empty_7zipfile, - functools.partial(create_zipfile, content=_ROM_HEADER, suffix=".exe"), - functools.partial(create_7zipfile, content=_ROM_HEADER, suffix=".exe"), - ), -) -def test_inspect_rom__missing_rom_in_archive( - tmp_dir: Path, - romfile_factory: Callable[[Path], Path], -) -> None: - rom_path = romfile_factory(tmp_dir) - - with pytest.raises( - app_errors.RomIOError, - match=re.escape("Archive doesn't contain ROM: *.nes"), - ): - _ = nes_app.inspect_rom(rom_path) - - -@pytest.mark.app -@pytest.mark.parametrize( - "patcher_args,archive_suffix,error", - ( - ((file_io.zipfile, "is_zipfile"), ".zip", "File is not a zip file"), # pyright: ignore[reportPrivateImportUsage, reportPrivateLocalImportUsage] - ((file_io.py7zr, "is_7zfile"), ".7z", "not a 7z file"), # pyright: ignore[reportPrivateImportUsage, reportPrivateLocalImportUsage] - ), -) -def test_inspect_rom__broken_archive( - monkeypatch: pytest.MonkeyPatch, - tmp_dir: Path, - patcher_args: tuple[ModuleType, str], - archive_suffix: str, - error: str, -) -> None: - # TODO: replace on manual creating broken files - with monkeypatch.context() as patcher: - patcher.setattr(*patcher_args, lambda _: True) # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType] - bad_archive_path = create_file(tmp_dir, b"", suffix=archive_suffix) - - with pytest.raises(app_errors.RomIOError, match=error): - _ = nes_app.inspect_rom(bad_archive_path) +import functools +import re +from collections.abc import Callable +from pathlib import Path +from types import ModuleType + +import pytest + +import romq.app.io.file as file_io +from romq.app import errors as app_errors +from romq.app import nes as nes_app + +from tests.app.utils import ( + create_7zipfile, + create_empty_7zipfile, + create_empty_zipfile, + create_file, + create_zipfile, +) + +# pyright: reportUnnecessaryTypeIgnoreComment=false + +_ROM_HEADER = b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00" + + +@pytest.mark.app +@pytest.mark.parametrize( + "romfile_factory", + (create_file, create_zipfile, create_7zipfile), +) +def test_inspect_rom__success( + tmp_dir: Path, + romfile_factory: Callable[[Path, bytes], Path], +) -> None: + rom_path = romfile_factory(tmp_dir, _ROM_HEADER) + expected_rom_info = nes_app.RomInfo( + filepath=rom_path, + metadata=nes_app.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_app.RomFormat.INES_20, + mirroring=nes_app.Mirroring.HORIZONTAL, + console_type=nes_app.ConsoleType.NES, + tv_system=nes_app.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ), + ) + + assert nes_app.inspect_rom(rom_path) == expected_rom_info + + +@pytest.mark.app +def test_inspect_rom__file_not_exist(tmp_dir: Path) -> None: + rom_path = tmp_dir.joinpath("test_inspect_rom__file_not_exist.nes") + with pytest.raises(FileNotFoundError): + _ = nes_app.inspect_rom(rom_path) + + +@pytest.mark.app +@pytest.mark.parametrize( + "header,error_regexp", + ( + ( + b"", + "ROM header is too small", + ), + ( + b"NES\x00\x01\x01\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00", + "Invalid iNES header ID", + ), + ( + b"NES\x1a\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00", + "Archaic iNES format is not supported", + ), + ( + b"NES\x1a\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00", + "Unknown ROM format bits: 0x03", + ), + ), +) +def test_inspect_rom__invalid_rom_header( + tmp_dir: Path, + header: bytes, + error_regexp: str, +) -> None: + rom_path = tmp_dir.joinpath("test_inspect_rom__expected_failures.nes") + _ = rom_path.write_bytes(header) + + with pytest.raises(app_errors.RomInspectError, match=error_regexp): + _ = nes_app.inspect_rom(rom_path) + + +@pytest.mark.app +@pytest.mark.parametrize( + "romfile_factory", + ( + create_empty_zipfile, + create_empty_7zipfile, + functools.partial(create_zipfile, content=_ROM_HEADER, suffix=".exe"), + functools.partial(create_7zipfile, content=_ROM_HEADER, suffix=".exe"), + ), +) +def test_inspect_rom__missing_rom_in_archive( + tmp_dir: Path, + romfile_factory: Callable[[Path], Path], +) -> None: + rom_path = romfile_factory(tmp_dir) + + with pytest.raises( + app_errors.RomIOError, + match=re.escape("Archive doesn't contain ROM: *.nes"), + ): + _ = nes_app.inspect_rom(rom_path) + + +@pytest.mark.app +@pytest.mark.parametrize( + "patcher_args,archive_suffix,error", + ( + ((file_io.zipfile, "is_zipfile"), ".zip", "File is not a zip file"), # pyright: ignore[reportPrivateImportUsage, reportPrivateLocalImportUsage] + ((file_io.py7zr, "is_7zfile"), ".7z", "not a 7z file"), # pyright: ignore[reportPrivateImportUsage, reportPrivateLocalImportUsage] + ), +) +def test_inspect_rom__broken_archive( + monkeypatch: pytest.MonkeyPatch, + tmp_dir: Path, + patcher_args: tuple[ModuleType, str], + archive_suffix: str, + error: str, +) -> None: + # TODO: replace on manual creating broken files + with monkeypatch.context() as patcher: + patcher.setattr(*patcher_args, lambda _: True) # pyright: ignore[reportUnknownArgumentType, reportUnknownLambdaType] + bad_archive_path = create_file(tmp_dir, b"", suffix=archive_suffix) + + with pytest.raises(app_errors.RomIOError, match=error): + _ = nes_app.inspect_rom(bad_archive_path) diff --git a/tests/app/test_nes_search.py b/tests/app/test_nes_search.py index 5a4afec..c8436a9 100644 --- a/tests/app/test_nes_search.py +++ b/tests/app/test_nes_search.py @@ -1,214 +1,214 @@ -from pathlib import Path - -import pytest - -from romq.app import nes as nes_app -from romq.core.nes.parser import errors as parser_errors - -from tests.app.utils import ( - create_7zipfile, - create_empty_7zipfile, - create_empty_zipfile, - create_file, - create_zipfile, -) - -_ROM1_HEADER = b"NES\x1a\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" -_ROM1_INFO = nes_app.RomInfo( - filepath=Path(), - metadata=nes_app.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_app.RomFormat.INES, - mirroring=nes_app.Mirroring.HORIZONTAL, - console_type=nes_app.ConsoleType.NES, - tv_system=nes_app.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ), -) - -_ROM2_HEADER = b"NES\x1a\x08\x00\x21\x08\x20\x00\x00\x07\x00\x00\x00\x01" -_ROM2_INFO = nes_app.RomInfo( - filepath=Path(), - metadata=nes_app.RomMetadata( - prg_rom_size=131072, - chr_rom_size=0, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=8192, - chr_nvram_size=0, - mapper_id=2, - submapper_id=2, - rom_format=nes_app.RomFormat.INES_20, - mirroring=nes_app.Mirroring.VERTICAL, - console_type=nes_app.ConsoleType.NES, - tv_system=nes_app.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ), -) - - -@pytest.mark.app -def test_search_roms__dir_not_exists(tmp_dir: Path) -> None: - with pytest.raises(FileNotFoundError, match=r"No such directory"): - _ = nes_app.search_roms(tmp_dir.joinpath("notexist.nes")) - - -@pytest.mark.app -def test_search_roms__not_directory(tmp_dir: Path) -> None: - filepath = create_file(tmp_dir, b"") - with pytest.raises( - NotADirectoryError, - match="Directory name is invalid", - ): - _ = nes_app.search_roms(filepath) - - -@pytest.mark.app -def test_search_roms__empty_dir(tmp_dir: Path) -> None: - result = nes_app.search_roms(tmp_dir) - assert len(result.matches) == 0 - assert len(result.failures) == 0 - - -@pytest.mark.app -def test_search_roms__match_any(tmp_dir: Path) -> None: - rom_path = create_file(tmp_dir, _ROM1_HEADER) - - zip_rom_path = create_zipfile(tmp_dir, _ROM2_HEADER) - _empty_zip_path = create_empty_zipfile(tmp_dir) - - sevenzip_rom_path = create_7zipfile(tmp_dir, _ROM1_HEADER) - _empty_7zip_path = create_empty_7zipfile(tmp_dir) - - _not_rom_path = create_file(tmp_dir, b"NES\x1a") - bad_rom_path = create_file( - tmp_dir, - b"NES\x1a\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00", - ) - - expected_matches = { - rom_path: _ROM1_INFO.metadata, - zip_rom_path: _ROM2_INFO.metadata, - sevenzip_rom_path: _ROM1_INFO.metadata, - } - expected_errors = { - bad_rom_path: nes_app.InspectionFailure( - bad_rom_path, - parser_errors.UnknownRomError("Unknown ROM format bits: 0x03"), - ) - } - - result = nes_app.search_roms(tmp_dir) - - assert len(result.matches) == len(expected_matches) - assert all( - expected_matches[match.filepath] == match.metadata - for match in result.matches - ) - - assert len(result.failures) == len(expected_errors) - for error in result.failures: - expected = expected_errors[error.filepath] - assert error.filepath == expected.filepath - assert error.error.__class__ is expected.error.__class__ - assert str(error.error) == str(expected.error) - - -@pytest.mark.app -def test_search_roms__match_filter(tmp_dir: Path) -> None: - ines_horiz_path = create_file(tmp_dir, _ROM1_HEADER) - _ = create_zipfile(tmp_dir, _ROM2_HEADER) - - result = nes_app.search_roms( - tmp_dir, - query=nes_app.SearchQuery( - mapper_id=_ROM1_INFO.metadata.mapper_id, - submapper_id=_ROM1_INFO.metadata.submapper_id, - rom_format=_ROM1_INFO.metadata.rom_format, - mirroring=_ROM1_INFO.metadata.mirroring, - console_type=_ROM1_INFO.metadata.console_type, - tv_system=_ROM1_INFO.metadata.tv_system, - has_battery=_ROM1_INFO.metadata.has_battery, - has_trainer=_ROM1_INFO.metadata.has_trainer, - has_alternate_nt=_ROM1_INFO.metadata.has_alternate_nt, - ), - ) - - assert len(result.failures) == 0 - assert len(result.matches) == 1 - - match = result.matches[0] - assert match.filepath == ines_horiz_path - assert match.metadata == _ROM1_INFO.metadata - - -@pytest.mark.app -def test_search_roms__match_predicate(tmp_dir: Path) -> None: - _ = create_file(tmp_dir, _ROM1_HEADER) - ines20_vert_path = create_zipfile(tmp_dir, _ROM2_HEADER) - - result = nes_app.search_roms( - tmp_dir, - predicate=lambda ri: ( - ri.rom_format is nes_app.RomFormat.INES_20 - and ri.mirroring is nes_app.Mirroring.VERTICAL - ), - ) - - assert len(result.failures) == 0 - assert len(result.matches) == 1 - - match = result.matches[0] - assert match.filepath == ines20_vert_path - assert match.metadata == _ROM2_INFO.metadata - - -@pytest.mark.app -def test_search_roms__predicate_overrides_filter_args(tmp_dir: Path) -> None: - _ = create_file(tmp_dir, _ROM1_HEADER) - vert_ines20_path = create_zipfile(tmp_dir, _ROM2_HEADER) - - result = nes_app.search_roms( - tmp_dir, - query=nes_app.SearchQuery( - rom_format=nes_app.RomFormat.INES, - mirroring=nes_app.Mirroring.HORIZONTAL, - ), - predicate=lambda ri: ( - ri.rom_format is nes_app.RomFormat.INES_20 - and ri.mirroring is nes_app.Mirroring.VERTICAL - ), - ) - - assert len(result.failures) == 0 - assert len(result.matches) == 1 - - match = result.matches[0] - assert match.filepath == vert_ines20_path - assert match.metadata == _ROM2_INFO.metadata - - -@pytest.mark.app -def test_search_roms__match_nothing(tmp_dir: Path) -> None: - _ = create_file(tmp_dir, _ROM1_HEADER) - - result = nes_app.search_roms( - tmp_dir, - query=nes_app.SearchQuery( - console_type=nes_app.ConsoleType.VS_UNISYSTEM, - ), - ) - - assert len(result.failures) == 0 - assert len(result.matches) == 0 +from pathlib import Path + +import pytest + +from romq.app import nes as nes_app +from romq.core.nes.parser import errors as parser_errors + +from tests.app.utils import ( + create_7zipfile, + create_empty_7zipfile, + create_empty_zipfile, + create_file, + create_zipfile, +) + +_ROM1_HEADER = b"NES\x1a\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +_ROM1_INFO = nes_app.RomInfo( + filepath=Path(), + metadata=nes_app.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_app.RomFormat.INES, + mirroring=nes_app.Mirroring.HORIZONTAL, + console_type=nes_app.ConsoleType.NES, + tv_system=nes_app.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ), +) + +_ROM2_HEADER = b"NES\x1a\x08\x00\x21\x08\x20\x00\x00\x07\x00\x00\x00\x01" +_ROM2_INFO = nes_app.RomInfo( + filepath=Path(), + metadata=nes_app.RomMetadata( + prg_rom_size=131072, + chr_rom_size=0, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=8192, + chr_nvram_size=0, + mapper_id=2, + submapper_id=2, + rom_format=nes_app.RomFormat.INES_20, + mirroring=nes_app.Mirroring.VERTICAL, + console_type=nes_app.ConsoleType.NES, + tv_system=nes_app.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ), +) + + +@pytest.mark.app +def test_search_roms__dir_not_exists(tmp_dir: Path) -> None: + with pytest.raises(FileNotFoundError, match=r"No such directory"): + _ = nes_app.search_roms(tmp_dir.joinpath("notexist.nes")) + + +@pytest.mark.app +def test_search_roms__not_directory(tmp_dir: Path) -> None: + filepath = create_file(tmp_dir, b"") + with pytest.raises( + NotADirectoryError, + match="Directory name is invalid", + ): + _ = nes_app.search_roms(filepath) + + +@pytest.mark.app +def test_search_roms__empty_dir(tmp_dir: Path) -> None: + result = nes_app.search_roms(tmp_dir) + assert len(result.matches) == 0 + assert len(result.failures) == 0 + + +@pytest.mark.app +def test_search_roms__match_any(tmp_dir: Path) -> None: + rom_path = create_file(tmp_dir, _ROM1_HEADER) + + zip_rom_path = create_zipfile(tmp_dir, _ROM2_HEADER) + _empty_zip_path = create_empty_zipfile(tmp_dir) + + sevenzip_rom_path = create_7zipfile(tmp_dir, _ROM1_HEADER) + _empty_7zip_path = create_empty_7zipfile(tmp_dir) + + _not_rom_path = create_file(tmp_dir, b"NES\x1a") + bad_rom_path = create_file( + tmp_dir, + b"NES\x1a\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00", + ) + + expected_matches = { + rom_path: _ROM1_INFO.metadata, + zip_rom_path: _ROM2_INFO.metadata, + sevenzip_rom_path: _ROM1_INFO.metadata, + } + expected_errors = { + bad_rom_path: nes_app.InspectionFailure( + bad_rom_path, + parser_errors.UnknownRomError("Unknown ROM format bits: 0x03"), + ) + } + + result = nes_app.search_roms(tmp_dir) + + assert len(result.matches) == len(expected_matches) + assert all( + expected_matches[match.filepath] == match.metadata + for match in result.matches + ) + + assert len(result.failures) == len(expected_errors) + for error in result.failures: + expected = expected_errors[error.filepath] + assert error.filepath == expected.filepath + assert error.error.__class__ is expected.error.__class__ + assert str(error.error) == str(expected.error) + + +@pytest.mark.app +def test_search_roms__match_filter(tmp_dir: Path) -> None: + ines_horiz_path = create_file(tmp_dir, _ROM1_HEADER) + _ = create_zipfile(tmp_dir, _ROM2_HEADER) + + result = nes_app.search_roms( + tmp_dir, + query=nes_app.SearchQuery( + mapper_id=_ROM1_INFO.metadata.mapper_id, + submapper_id=_ROM1_INFO.metadata.submapper_id, + rom_format=_ROM1_INFO.metadata.rom_format, + mirroring=_ROM1_INFO.metadata.mirroring, + console_type=_ROM1_INFO.metadata.console_type, + tv_system=_ROM1_INFO.metadata.tv_system, + has_battery=_ROM1_INFO.metadata.has_battery, + has_trainer=_ROM1_INFO.metadata.has_trainer, + has_alternate_nt=_ROM1_INFO.metadata.has_alternate_nt, + ), + ) + + assert len(result.failures) == 0 + assert len(result.matches) == 1 + + match = result.matches[0] + assert match.filepath == ines_horiz_path + assert match.metadata == _ROM1_INFO.metadata + + +@pytest.mark.app +def test_search_roms__match_predicate(tmp_dir: Path) -> None: + _ = create_file(tmp_dir, _ROM1_HEADER) + ines20_vert_path = create_zipfile(tmp_dir, _ROM2_HEADER) + + result = nes_app.search_roms( + tmp_dir, + predicate=lambda ri: ( + ri.rom_format is nes_app.RomFormat.INES_20 + and ri.mirroring is nes_app.Mirroring.VERTICAL + ), + ) + + assert len(result.failures) == 0 + assert len(result.matches) == 1 + + match = result.matches[0] + assert match.filepath == ines20_vert_path + assert match.metadata == _ROM2_INFO.metadata + + +@pytest.mark.app +def test_search_roms__predicate_overrides_filter_args(tmp_dir: Path) -> None: + _ = create_file(tmp_dir, _ROM1_HEADER) + vert_ines20_path = create_zipfile(tmp_dir, _ROM2_HEADER) + + result = nes_app.search_roms( + tmp_dir, + query=nes_app.SearchQuery( + rom_format=nes_app.RomFormat.INES, + mirroring=nes_app.Mirroring.HORIZONTAL, + ), + predicate=lambda ri: ( + ri.rom_format is nes_app.RomFormat.INES_20 + and ri.mirroring is nes_app.Mirroring.VERTICAL + ), + ) + + assert len(result.failures) == 0 + assert len(result.matches) == 1 + + match = result.matches[0] + assert match.filepath == vert_ines20_path + assert match.metadata == _ROM2_INFO.metadata + + +@pytest.mark.app +def test_search_roms__match_nothing(tmp_dir: Path) -> None: + _ = create_file(tmp_dir, _ROM1_HEADER) + + result = nes_app.search_roms( + tmp_dir, + query=nes_app.SearchQuery( + console_type=nes_app.ConsoleType.VS_UNISYSTEM, + ), + ) + + assert len(result.failures) == 0 + assert len(result.matches) == 0 diff --git a/tests/app/utils.py b/tests/app/utils.py index 7937997..3452f61 100644 --- a/tests/app/utils.py +++ b/tests/app/utils.py @@ -1,55 +1,55 @@ -import tempfile -import zipfile -from pathlib import Path - -import py7zr - - -def create_file(tmp_dir: Path, content: bytes, suffix: str = "") -> Path: - with tempfile.NamedTemporaryFile( - dir=tmp_dir, - suffix=suffix or ".nes", - delete=False, - ) as tmpfile: - _ = tmpfile.write(content) - return Path(tmpfile.name) - - -def create_empty_zipfile(tmp_dir: Path) -> Path: - with ( - tempfile.NamedTemporaryFile( - dir=tmp_dir, - suffix=".zip", - delete=False, - ) as tmp_zipfile, - zipfile.ZipFile(tmp_zipfile, "w"), - ): - return Path(tmp_zipfile.name) - - -def create_zipfile(tmp_dir: Path, content: bytes, suffix: str = "") -> Path: - zip_path = create_empty_zipfile(tmp_dir) - with zipfile.ZipFile(zip_path, "w") as zip_file: - file_suffix = suffix or ".nes" - zip_file.writestr(f"rom{file_suffix}", content) - return zip_path - - -def create_empty_7zipfile(tmp_dir: Path) -> Path: - with ( - tempfile.NamedTemporaryFile( - dir=tmp_dir, - suffix=".7z", - delete=False, - ) as tmp_7zipfile, - py7zr.SevenZipFile(tmp_7zipfile.file, "w"), - ): - return Path(tmp_7zipfile.name) - - -def create_7zipfile(tmp_dir: Path, content: bytes, suffix: str = "") -> Path: - sevenzip_path = create_empty_7zipfile(tmp_dir) - with py7zr.SevenZipFile(sevenzip_path, "w") as sevenzip_file: - file_suffix = suffix or ".nes" - sevenzip_file.writestr(content, f"rom{file_suffix}") - return sevenzip_path +import tempfile +import zipfile +from pathlib import Path + +import py7zr + + +def create_file(tmp_dir: Path, content: bytes, suffix: str = "") -> Path: + with tempfile.NamedTemporaryFile( + dir=tmp_dir, + suffix=suffix or ".nes", + delete=False, + ) as tmpfile: + _ = tmpfile.write(content) + return Path(tmpfile.name) + + +def create_empty_zipfile(tmp_dir: Path) -> Path: + with ( + tempfile.NamedTemporaryFile( + dir=tmp_dir, + suffix=".zip", + delete=False, + ) as tmp_zipfile, + zipfile.ZipFile(tmp_zipfile, "w"), + ): + return Path(tmp_zipfile.name) + + +def create_zipfile(tmp_dir: Path, content: bytes, suffix: str = "") -> Path: + zip_path = create_empty_zipfile(tmp_dir) + with zipfile.ZipFile(zip_path, "w") as zip_file: + file_suffix = suffix or ".nes" + zip_file.writestr(f"rom{file_suffix}", content) + return zip_path + + +def create_empty_7zipfile(tmp_dir: Path) -> Path: + with ( + tempfile.NamedTemporaryFile( + dir=tmp_dir, + suffix=".7z", + delete=False, + ) as tmp_7zipfile, + py7zr.SevenZipFile(tmp_7zipfile.file, "w"), + ): + return Path(tmp_7zipfile.name) + + +def create_7zipfile(tmp_dir: Path, content: bytes, suffix: str = "") -> Path: + sevenzip_path = create_empty_7zipfile(tmp_dir) + with py7zr.SevenZipFile(sevenzip_path, "w") as sevenzip_file: + file_suffix = suffix or ".nes" + sevenzip_file.writestr(content, f"rom{file_suffix}") + return sevenzip_path diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py index ce25082..482a9cf 100644 --- a/tests/cli/conftest.py +++ b/tests/cli/conftest.py @@ -1,17 +1,17 @@ -import click.testing -import pytest - - -@pytest.fixture(autouse=True) -def freeze_columns(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("COLUMNS", "80") - - -@pytest.fixture(params=("-h", "--help")) -def help_option(request: pytest.FixtureRequest) -> str: - return request.param # pyright: ignore[reportAny] - - -@pytest.fixture -def cli_runner() -> click.testing.CliRunner: - return click.testing.CliRunner() +import click.testing +import pytest + + +@pytest.fixture(autouse=True) +def freeze_columns(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("COLUMNS", "80") + + +@pytest.fixture(params=("-h", "--help")) +def help_option(request: pytest.FixtureRequest) -> str: + return request.param # pyright: ignore[reportAny] + + +@pytest.fixture +def cli_runner() -> click.testing.CliRunner: + return click.testing.CliRunner() diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index dd33d7f..bdc5c17 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -1,57 +1,57 @@ -import textwrap - -import click.testing -import pytest - -from romq import __version__, cli - -_MAIN_HELP = textwrap.dedent( - """\ -Usage: romq [OPTIONS] COMMAND [ARGS]... - - A CLI utility for inspecting ROM metadata and searching directories for ROM - files based on specific criteria. - -Options: - --version Show the version and exit. - -h, --help Show this message and exit. - -Commands: - nes NES ROM inspection and search utilities -""" -) - - -@pytest.mark.cli -def test_get_version(cli_runner: click.testing.CliRunner) -> None: - result = cli_runner.invoke(cli.main, ["--version"]) - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stdout == f"romq {__version__}\n" - - -@pytest.mark.cli -def test_main__help( - cli_runner: click.testing.CliRunner, - help_option: str, -) -> None: - result = cli_runner.invoke(cli.main, [help_option]) - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stdout == _MAIN_HELP - - -@pytest.mark.cli -def test_main__missing_required_arguments( - cli_runner: click.testing.CliRunner, -) -> None: - result = cli_runner.invoke(cli.main, []) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert result.stderr == _MAIN_HELP - - -@pytest.mark.cli -def test_main__invalid_arguments( - cli_runner: click.testing.CliRunner, -) -> None: - result = cli_runner.invoke(cli.main, ["invalid_arg"]) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert "No such command 'invalid_arg'" in result.stderr +import textwrap + +import click.testing +import pytest + +from romq import __version__, cli + +_MAIN_HELP = textwrap.dedent( + """\ +Usage: romq [OPTIONS] COMMAND [ARGS]... + + A CLI utility for inspecting ROM metadata and searching directories for ROM + files based on specific criteria. + +Options: + --version Show the version and exit. + -h, --help Show this message and exit. + +Commands: + nes NES ROM inspection and search utilities +""" +) + + +@pytest.mark.cli +def test_get_version(cli_runner: click.testing.CliRunner) -> None: + result = cli_runner.invoke(cli.main, ["--version"]) + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stdout == f"romq {__version__}\n" + + +@pytest.mark.cli +def test_main__help( + cli_runner: click.testing.CliRunner, + help_option: str, +) -> None: + result = cli_runner.invoke(cli.main, [help_option]) + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stdout == _MAIN_HELP + + +@pytest.mark.cli +def test_main__missing_required_arguments( + cli_runner: click.testing.CliRunner, +) -> None: + result = cli_runner.invoke(cli.main, []) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert result.stderr == _MAIN_HELP + + +@pytest.mark.cli +def test_main__invalid_arguments( + cli_runner: click.testing.CliRunner, +) -> None: + result = cli_runner.invoke(cli.main, ["invalid_arg"]) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert "No such command 'invalid_arg'" in result.stderr diff --git a/tests/cli/test_nes.py b/tests/cli/test_nes.py index 4ede27f..8fb200e 100644 --- a/tests/cli/test_nes.py +++ b/tests/cli/test_nes.py @@ -1,49 +1,49 @@ -import textwrap - -import click.testing -import pytest - -from romq import cli - -_NES_HELP = textwrap.dedent( - """\ -Usage: romq nes [OPTIONS] COMMAND [ARGS]... - - NES ROM inspection and search utilities - -Options: - -h, --help Show this message and exit. - -Commands: - inspect Display metadata for a single NES ROM file or an archive (ZIP/7z). - search Search NES ROMs in a directory using hardware and format filters. -""" -) - - -@pytest.mark.cli -def test_nes__help( - cli_runner: click.testing.CliRunner, - help_option: str, -) -> None: - result = cli_runner.invoke(cli.main, ["nes", help_option]) - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stdout == _NES_HELP - - -@pytest.mark.cli -def test_nes__missing_required_arguments( - cli_runner: click.testing.CliRunner, -) -> None: - result = cli_runner.invoke(cli.main, ["nes"]) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert result.stderr == _NES_HELP - - -@pytest.mark.cli -def test_nes__invalid_arguments( - cli_runner: click.testing.CliRunner, -) -> None: - result = cli_runner.invoke(cli.main, ["nes", "invalid_arg"]) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert "No such command 'invalid_arg'" in result.stderr +import textwrap + +import click.testing +import pytest + +from romq import cli + +_NES_HELP = textwrap.dedent( + """\ +Usage: romq nes [OPTIONS] COMMAND [ARGS]... + + NES ROM inspection and search utilities + +Options: + -h, --help Show this message and exit. + +Commands: + inspect Display metadata for a single NES ROM file or an archive (ZIP/7z). + search Search NES ROMs in a directory using hardware and format filters. +""" +) + + +@pytest.mark.cli +def test_nes__help( + cli_runner: click.testing.CliRunner, + help_option: str, +) -> None: + result = cli_runner.invoke(cli.main, ["nes", help_option]) + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stdout == _NES_HELP + + +@pytest.mark.cli +def test_nes__missing_required_arguments( + cli_runner: click.testing.CliRunner, +) -> None: + result = cli_runner.invoke(cli.main, ["nes"]) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert result.stderr == _NES_HELP + + +@pytest.mark.cli +def test_nes__invalid_arguments( + cli_runner: click.testing.CliRunner, +) -> None: + result = cli_runner.invoke(cli.main, ["nes", "invalid_arg"]) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert "No such command 'invalid_arg'" in result.stderr diff --git a/tests/cli/test_nes_inspect.py b/tests/cli/test_nes_inspect.py index c338036..2553588 100644 --- a/tests/cli/test_nes_inspect.py +++ b/tests/cli/test_nes_inspect.py @@ -1,164 +1,164 @@ -import functools -import textwrap -from pathlib import Path - -import click.testing -import pytest - -from romq import cli -from romq.app import errors as app_errors -from romq.app import nes - -from tests.cli.utils import clear_ansi_escape, mock_app_func - -_mock_inspect = functools.partial(mock_app_func, nes, "inspect_rom") - -_ROM_INFO = nes.RomInfo( - filepath=Path("rom.nes"), - metadata=nes.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=4, - submapper_id=0, - rom_format=nes.RomFormat.INES_20, - mirroring=nes.Mirroring.HORIZONTAL, - console_type=nes.ConsoleType.NES, - tv_system=nes.TvSystem.NTSC, - has_battery=False, - has_trainer=True, - has_alternate_nt=False, - ), -) - - -@pytest.mark.cli -def test_inspect_rom__help( - cli_runner: click.testing.CliRunner, - help_option: str, -) -> None: - expected_output = textwrap.dedent( - """\ - Usage: romq nes inspect [OPTIONS] ROM_PATH - - Display metadata for a single NES ROM file or an archive (ZIP/7z). - - Options: - -h, --help Show this message and exit. - """ - ) - - result = cli_runner.invoke(cli.main, ["nes", "inspect", help_option]) - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stdout == expected_output - - -@pytest.mark.cli -def test_inspect_rom__missing_required_arguments( - cli_runner: click.testing.CliRunner, -) -> None: - result = cli_runner.invoke(cli.main, ["nes", "inspect"]) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert "Missing argument 'ROM_PATH'" in result.stderr - - -@pytest.mark.cli -def test_inspect_rom__valid_options( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, -) -> None: - with _mock_inspect(monkeypatch, _ROM_INFO) as mock: - rom_path = "rom.nes" - result = cli_runner.invoke( - cli.main, - ["nes", "inspect", rom_path], - ) - assert result.exit_code == cli.statuses.ES_SUCCESS - mock.assert_called_once_with(rom_path) - - -@pytest.mark.cli -def test_inspect_rom__success_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, -) -> None: - expected_output = textwrap.dedent( - """\ - ROM: rom.nes - Format iNES 2.0 - Console NES - TV System RP2C02 (NTSC NES) - Mapper 4 - Submapper 0 - Mirroring Horizontal - Trainer True - Alternate NT False - Battery-backed False - PRG ROM 16384 - CHR ROM 8192 - PRG RAM 0 - PRG NVRAM 0 - CHR RAM 0 - CHR NVRAM 0 - """ - ) - with _mock_inspect(monkeypatch, _ROM_INFO): - result = cli_runner.invoke( - cli.main, - ["nes", "inspect", str(_ROM_INFO.filepath)], - ) - assert result.exit_code == cli.statuses.ES_SUCCESS - assert clear_ansi_escape(result.stdout) == expected_output - - -@pytest.mark.cli -@pytest.mark.parametrize( - "error", - ( - FileNotFoundError("File not found"), - PermissionError("Cannot access to file"), - app_errors.RomIOError("Archive doesn't contain ROM"), - app_errors.RomInspectError("Invalid header ID"), - ), -) -def test_inspect_rom__expected_failure_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, - error: Exception, -) -> None: - with _mock_inspect(monkeypatch, error): - result = cli_runner.invoke( - cli.main, - ["nes", "inspect", "rom.nes"], - ) - assert result.exit_code == cli.statuses.ES_ERROR - assert result.stdout == "" - assert clear_ansi_escape(result.stderr) == f"{error}\n" - - -@pytest.mark.cli -@pytest.mark.parametrize( - "error", - ( - RuntimeError("something went wrong.."), - ValueError("value error"), - TypeError("type error"), - AttributeError("attribute error"), - ), -) -def test_inspect_rom__unexpected_failure_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, - error: Exception, -) -> None: - with _mock_inspect(monkeypatch, error): - result = cli_runner.invoke( - cli.main, - ["nes", "inspect", "rom.nes"], - ) - assert result.exit_code == cli.statuses.ES_INTERNAL_ERROR - assert result.stdout == "" - assert clear_ansi_escape(result.stderr) == f"{error}\n" +import functools +import textwrap +from pathlib import Path + +import click.testing +import pytest + +from romq import cli +from romq.app import errors as app_errors +from romq.app import nes + +from tests.cli.utils import clear_ansi_escape, mock_app_func + +_mock_inspect = functools.partial(mock_app_func, nes, "inspect_rom") + +_ROM_INFO = nes.RomInfo( + filepath=Path("rom.nes"), + metadata=nes.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=4, + submapper_id=0, + rom_format=nes.RomFormat.INES_20, + mirroring=nes.Mirroring.HORIZONTAL, + console_type=nes.ConsoleType.NES, + tv_system=nes.TvSystem.NTSC, + has_battery=False, + has_trainer=True, + has_alternate_nt=False, + ), +) + + +@pytest.mark.cli +def test_inspect_rom__help( + cli_runner: click.testing.CliRunner, + help_option: str, +) -> None: + expected_output = textwrap.dedent( + """\ + Usage: romq nes inspect [OPTIONS] ROM_PATH + + Display metadata for a single NES ROM file or an archive (ZIP/7z). + + Options: + -h, --help Show this message and exit. + """ + ) + + result = cli_runner.invoke(cli.main, ["nes", "inspect", help_option]) + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stdout == expected_output + + +@pytest.mark.cli +def test_inspect_rom__missing_required_arguments( + cli_runner: click.testing.CliRunner, +) -> None: + result = cli_runner.invoke(cli.main, ["nes", "inspect"]) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert "Missing argument 'ROM_PATH'" in result.stderr + + +@pytest.mark.cli +def test_inspect_rom__valid_options( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, +) -> None: + with _mock_inspect(monkeypatch, _ROM_INFO) as mock: + rom_path = "rom.nes" + result = cli_runner.invoke( + cli.main, + ["nes", "inspect", rom_path], + ) + assert result.exit_code == cli.statuses.ES_SUCCESS + mock.assert_called_once_with(rom_path) + + +@pytest.mark.cli +def test_inspect_rom__success_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, +) -> None: + expected_output = textwrap.dedent( + """\ + ROM: rom.nes + Format iNES 2.0 + Console NES + TV System RP2C02 (NTSC NES) + Mapper 4 + Submapper 0 + Mirroring Horizontal + Trainer True + Alternate NT False + Battery-backed False + PRG ROM 16384 + CHR ROM 8192 + PRG RAM 0 + PRG NVRAM 0 + CHR RAM 0 + CHR NVRAM 0 + """ + ) + with _mock_inspect(monkeypatch, _ROM_INFO): + result = cli_runner.invoke( + cli.main, + ["nes", "inspect", str(_ROM_INFO.filepath)], + ) + assert result.exit_code == cli.statuses.ES_SUCCESS + assert clear_ansi_escape(result.stdout) == expected_output + + +@pytest.mark.cli +@pytest.mark.parametrize( + "error", + ( + FileNotFoundError("File not found"), + PermissionError("Cannot access to file"), + app_errors.RomIOError("Archive doesn't contain ROM"), + app_errors.RomInspectError("Invalid header ID"), + ), +) +def test_inspect_rom__expected_failure_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, + error: Exception, +) -> None: + with _mock_inspect(monkeypatch, error): + result = cli_runner.invoke( + cli.main, + ["nes", "inspect", "rom.nes"], + ) + assert result.exit_code == cli.statuses.ES_ERROR + assert result.stdout == "" + assert clear_ansi_escape(result.stderr) == f"{error}\n" + + +@pytest.mark.cli +@pytest.mark.parametrize( + "error", + ( + RuntimeError("something went wrong.."), + ValueError("value error"), + TypeError("type error"), + AttributeError("attribute error"), + ), +) +def test_inspect_rom__unexpected_failure_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, + error: Exception, +) -> None: + with _mock_inspect(monkeypatch, error): + result = cli_runner.invoke( + cli.main, + ["nes", "inspect", "rom.nes"], + ) + assert result.exit_code == cli.statuses.ES_INTERNAL_ERROR + assert result.stdout == "" + assert clear_ansi_escape(result.stderr) == f"{error}\n" diff --git a/tests/cli/test_nes_search.py b/tests/cli/test_nes_search.py index 7275ec6..c9f2e89 100644 --- a/tests/cli/test_nes_search.py +++ b/tests/cli/test_nes_search.py @@ -1,414 +1,414 @@ -import functools -import textwrap -from collections.abc import Iterable -from pathlib import Path - -import click.testing -import pytest - -from romq import cli -from romq.app import nes - -from tests.cli.utils import clear_ansi_escape, mock_app_func - -_mock_search = functools.partial(mock_app_func, nes, "search_roms") - - -def _make_search_query( # noqa: PLR0913 - mapper_id: int | None = None, - submapper_id: int | None = None, - rom_format: nes.RomFormat | None = None, - mirroring: nes.Mirroring | None = None, - console_type: nes.ConsoleType | None = None, - tv_system: nes.TvSystem | None = None, - has_battery: bool | None = None, - has_trainer: bool | None = None, - has_alternate_nt: bool | None = None, -) -> nes.SearchQuery: - return nes.SearchQuery( - mapper_id=mapper_id, - submapper_id=submapper_id, - rom_format=rom_format, - mirroring=mirroring, - console_type=console_type, - tv_system=tv_system, - has_battery=has_battery, - has_trainer=has_trainer, - has_alternate_nt=has_alternate_nt, - ) - - -@pytest.mark.cli -def test_search_roms__help( - cli_runner: click.testing.CliRunner, - help_option: str, -) -> None: - expected_output = textwrap.dedent( - """\ - Usage: romq nes search [OPTIONS] ROOT_DIR - - Search NES ROMs in a directory using hardware and format filters. - - Options: - -m, --mapper-id INTEGER Mapper ID (e.g., 0, 1, 4) - -s, --submapper-id INTEGER Submapper ID - -f, --rom-format [ines|ines_20] - iNES format version (1.0 or 2.0) - -r, --mirroring [vertical|horizontal] - Mirroring mode - -c, --console [nes|vs_unisystem|playchoice_10] - Console type - -v, --tv-system [ntsc|pal|dendy|multiple_region] - TV system - --battery / --no-battery Battery-backed RAM - --trainer / --no-trainer Trainer present - --alt-nt / --no-alt-nt Alternate nametable layout - -h, --help Show this message and exit. - """ - ) - - result = cli_runner.invoke(cli.main, ["nes", "search", help_option]) - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stdout == expected_output - - -@pytest.mark.cli -def test_search_roms__missing_required_arguments( - cli_runner: click.testing.CliRunner, -) -> None: - result = cli_runner.invoke(cli.main, ["nes", "search"]) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert "Missing argument 'ROOT_DIR'" in result.stderr - - -@pytest.mark.cli -@pytest.mark.parametrize( - "options,search_query", - ( - # empty - ( - (), - _make_search_query(), - ), - # mapper - ( - ("-m", "0"), - _make_search_query(mapper_id=0), - ), - ( - ("--mapper-id", "0"), - _make_search_query(mapper_id=0), - ), - # submapper - ( - ("-s", "0"), - _make_search_query(submapper_id=0), - ), - ( - ("--submapper-id", "0"), - _make_search_query(submapper_id=0), - ), - # rom format - ( - ("-f", "ines"), - _make_search_query(rom_format=nes.RomFormat.INES), - ), - ( - ("--rom-format", "ines_20"), - _make_search_query(rom_format=nes.RomFormat.INES_20), - ), - # mirroring - ( - ("-r", "horizontal"), - _make_search_query(mirroring=nes.Mirroring.HORIZONTAL), - ), - ( - ("--mirroring", "vertical"), - _make_search_query(mirroring=nes.Mirroring.VERTICAL), - ), - # console - ( - ("-c", "nes"), - _make_search_query(console_type=nes.ConsoleType.NES), - ), - ( - ("--console", "playchoice_10"), - _make_search_query(console_type=nes.ConsoleType.PLAYCHOICE_10), - ), - ( - ("--console", "vs_unisystem"), - _make_search_query(console_type=nes.ConsoleType.VS_UNISYSTEM), - ), - # tv system - ( - ("-v", "ntsc"), - _make_search_query(tv_system=nes.TvSystem.NTSC), - ), - ( - ("--tv-system", "pal"), - _make_search_query(tv_system=nes.TvSystem.PAL), - ), - ( - ("--tv-system", "multiple_region"), - _make_search_query(tv_system=nes.TvSystem.MULTIPLE_REGION), - ), - ( - ("--tv-system", "dendy"), - _make_search_query(tv_system=nes.TvSystem.DENDY), - ), - # battery - ( - ("--battery",), - _make_search_query(has_battery=True), - ), - ( - ("--no-battery",), - _make_search_query(has_battery=False), - ), - # trainer - ( - ("--trainer",), - _make_search_query(has_trainer=True), - ), - ( - ("--no-trainer",), - _make_search_query(has_trainer=False), - ), - # alt NT layout - ( - ("--alt-nt",), - _make_search_query(has_alternate_nt=True), - ), - ( - ("--no-alt-nt",), - _make_search_query(has_alternate_nt=False), - ), - # all - ( - ( - "--mapper-id", "4", - "--submapper-id", "0", - "--rom-format", "ines_20", - "--mirroring", "horizontal", - "--console", "nes", - "--tv-system", "ntsc", - "--battery", - "--trainer", - "--alt-nt", - ), - _make_search_query( - mapper_id=4, - submapper_id=0, - rom_format=nes.RomFormat.INES_20, - mirroring=nes.Mirroring.HORIZONTAL, - console_type=nes.ConsoleType.NES, - tv_system=nes.TvSystem.NTSC, - has_battery=True, - has_trainer=True, - has_alternate_nt=True, - ) - ), - ) -) # fmt: skip -def test_search_roms__valid_options( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, - options: Iterable[str], - search_query: nes.SearchQuery, -) -> None: - with _mock_search(monkeypatch, nes.SearchResult([], [])) as mock: - root_dir = "root_dir" - result = cli_runner.invoke( - cli.main, - ["nes", "search", root_dir, *options], - ) - - assert result.exit_code == cli.statuses.ES_SUCCESS - mock.assert_called_once_with(root_dir, query=search_query) - - -# ruff: disable[E501] -@pytest.mark.cli -@pytest.mark.parametrize( - "options,expected_error", - ( - ( - ("--mapper-id", "a"), - "Invalid value for '-m' / '--mapper-id': 'a' is not a valid integer", - ), - ( - ("--submapper-id", "a"), - "Invalid value for '-s' / '--submapper-id': 'a' is not a valid integer", - ), - ( - ("--rom-format", "archaic_ines"), - "Invalid value for '-f' / '--rom-format': 'archaic_ines' is not one of 'ines', 'ines_20'", - ), - ( - ("--mirroring", "hor"), - "Invalid value for '-r' / '--mirroring': 'hor' is not one of 'vertical', 'horizontal'", - ), - ( - ("--console", "default"), - "Invalid value for '-c' / '--console': 'default' is not one of 'nes', 'vs_unisystem', 'playchoice_10'.", - ), - ( - ("--tv-system", "HD"), - "Invalid value for '-v' / '--tv-system': 'HD' is not one of 'ntsc', 'pal', 'dendy', 'multiple_region'", - ), - ( - ("--battery", "yes"), - "Got unexpected extra argument (yes)", - ), - ), -) -# ruff: enable[E501] -# fmt: skip -def test_search_roms__invalid_options( - cli_runner: click.testing.CliRunner, - options: Iterable[str], - expected_error: str, -) -> None: - result = cli_runner.invoke( - cli.main, - ["nes", "search", "root_dir", *options], - ) - assert result.exit_code == cli.statuses.ES_USAGE_ERROR - assert result.stdout == "" - assert expected_error in result.stderr - - -@pytest.mark.cli -def test_search_roms__empty_result_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, -) -> None: - with _mock_search(monkeypatch, nes.SearchResult([], [])): - result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) - - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stderr == "" - assert ( - clear_ansi_escape(result.stdout) == "ROM matches: 0, Errors: 0\n" - ) - - -@pytest.mark.cli -def test_search_roms__mixed_result_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, -) -> None: - rom_info1 = nes.RomInfo( - filepath=Path("rom.nes"), - metadata=nes.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=4, - submapper_id=0, - rom_format=nes.RomFormat.INES_20, - mirroring=nes.Mirroring.HORIZONTAL, - console_type=nes.ConsoleType.NES, - tv_system=nes.TvSystem.NTSC, - has_battery=False, - has_trainer=True, - has_alternate_nt=False, - ), - ) - rom_info2 = nes.RomInfo( - filepath=Path("rom2.nes"), - metadata=nes.RomMetadata( - prg_rom_size=131072, - chr_rom_size=0, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=8192, - chr_nvram_size=0, - mapper_id=2, - submapper_id=2, - rom_format=nes.RomFormat.INES_20, - mirroring=nes.Mirroring.VERTICAL, - console_type=nes.ConsoleType.NES, - tv_system=nes.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ), - ) - search_result = nes.SearchResult( - matches=[rom_info1, rom_info2], - failures=[ - nes.InspectionFailure( - Path("invalid.nes"), - ValueError("invalid header"), - ), - ], - ) - # ruff: disable[E501] - expected_output = textwrap.dedent( - """\ - rom.nes - [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 16384 | CHR 8192] - rom2.nes - [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 2 | Vertical | PRG 131072 | CHR 0] - invalid.nes - invalid header - ROM matches: 2, Errors: 1 - """ - ) - # ruff: enable[E501] - - with _mock_search(monkeypatch, search_result): - result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) - - assert result.exit_code == cli.statuses.ES_SUCCESS - assert result.stderr == "" - assert clear_ansi_escape(result.stdout) == expected_output - - -@pytest.mark.cli -@pytest.mark.parametrize( - "error", - ( - FileNotFoundError("File not found"), - PermissionError("Cannot access to file"), - NotADirectoryError("Directory name is invalid"), - ), -) -def test_search_roms__expected_failure_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, - error: Exception, -) -> None: - with _mock_search(monkeypatch, error): - result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) - - assert result.exit_code == cli.statuses.ES_ERROR - assert result.stdout == "" - assert clear_ansi_escape(result.stderr) == f"{error}\n" - - -@pytest.mark.cli -@pytest.mark.parametrize( - "error", - ( - RuntimeError("Something went wrong.."), - ValueError("Value error"), - TypeError("Type error"), - AttributeError("Attribute error"), - ), -) -def test_search_roms__unexpected_failure_output( - monkeypatch: pytest.MonkeyPatch, - cli_runner: click.testing.CliRunner, - error: Exception, -) -> None: - with _mock_search(monkeypatch, error): - result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) - - assert result.exit_code == cli.statuses.ES_INTERNAL_ERROR - assert result.stdout == "" - assert clear_ansi_escape(result.stderr) == f"{error}\n" +import functools +import textwrap +from collections.abc import Iterable +from pathlib import Path + +import click.testing +import pytest + +from romq import cli +from romq.app import nes + +from tests.cli.utils import clear_ansi_escape, mock_app_func + +_mock_search = functools.partial(mock_app_func, nes, "search_roms") + + +def _make_search_query( # noqa: PLR0913 + mapper_id: int | None = None, + submapper_id: int | None = None, + rom_format: nes.RomFormat | None = None, + mirroring: nes.Mirroring | None = None, + console_type: nes.ConsoleType | None = None, + tv_system: nes.TvSystem | None = None, + has_battery: bool | None = None, + has_trainer: bool | None = None, + has_alternate_nt: bool | None = None, +) -> nes.SearchQuery: + return nes.SearchQuery( + mapper_id=mapper_id, + submapper_id=submapper_id, + rom_format=rom_format, + mirroring=mirroring, + console_type=console_type, + tv_system=tv_system, + has_battery=has_battery, + has_trainer=has_trainer, + has_alternate_nt=has_alternate_nt, + ) + + +@pytest.mark.cli +def test_search_roms__help( + cli_runner: click.testing.CliRunner, + help_option: str, +) -> None: + expected_output = textwrap.dedent( + """\ + Usage: romq nes search [OPTIONS] ROOT_DIR + + Search NES ROMs in a directory using hardware and format filters. + + Options: + -m, --mapper-id INTEGER Mapper ID (e.g., 0, 1, 4) + -s, --submapper-id INTEGER Submapper ID + -f, --rom-format [ines|ines_20] + iNES format version (1.0 or 2.0) + -r, --mirroring [vertical|horizontal] + Mirroring mode + -c, --console [nes|vs_unisystem|playchoice_10] + Console type + -v, --tv-system [ntsc|pal|dendy|multiple_region] + TV system + --battery / --no-battery Battery-backed RAM + --trainer / --no-trainer Trainer present + --alt-nt / --no-alt-nt Alternate nametable layout + -h, --help Show this message and exit. + """ + ) + + result = cli_runner.invoke(cli.main, ["nes", "search", help_option]) + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stdout == expected_output + + +@pytest.mark.cli +def test_search_roms__missing_required_arguments( + cli_runner: click.testing.CliRunner, +) -> None: + result = cli_runner.invoke(cli.main, ["nes", "search"]) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert "Missing argument 'ROOT_DIR'" in result.stderr + + +@pytest.mark.cli +@pytest.mark.parametrize( + "options,search_query", + ( + # empty + ( + (), + _make_search_query(), + ), + # mapper + ( + ("-m", "0"), + _make_search_query(mapper_id=0), + ), + ( + ("--mapper-id", "0"), + _make_search_query(mapper_id=0), + ), + # submapper + ( + ("-s", "0"), + _make_search_query(submapper_id=0), + ), + ( + ("--submapper-id", "0"), + _make_search_query(submapper_id=0), + ), + # rom format + ( + ("-f", "ines"), + _make_search_query(rom_format=nes.RomFormat.INES), + ), + ( + ("--rom-format", "ines_20"), + _make_search_query(rom_format=nes.RomFormat.INES_20), + ), + # mirroring + ( + ("-r", "horizontal"), + _make_search_query(mirroring=nes.Mirroring.HORIZONTAL), + ), + ( + ("--mirroring", "vertical"), + _make_search_query(mirroring=nes.Mirroring.VERTICAL), + ), + # console + ( + ("-c", "nes"), + _make_search_query(console_type=nes.ConsoleType.NES), + ), + ( + ("--console", "playchoice_10"), + _make_search_query(console_type=nes.ConsoleType.PLAYCHOICE_10), + ), + ( + ("--console", "vs_unisystem"), + _make_search_query(console_type=nes.ConsoleType.VS_UNISYSTEM), + ), + # tv system + ( + ("-v", "ntsc"), + _make_search_query(tv_system=nes.TvSystem.NTSC), + ), + ( + ("--tv-system", "pal"), + _make_search_query(tv_system=nes.TvSystem.PAL), + ), + ( + ("--tv-system", "multiple_region"), + _make_search_query(tv_system=nes.TvSystem.MULTIPLE_REGION), + ), + ( + ("--tv-system", "dendy"), + _make_search_query(tv_system=nes.TvSystem.DENDY), + ), + # battery + ( + ("--battery",), + _make_search_query(has_battery=True), + ), + ( + ("--no-battery",), + _make_search_query(has_battery=False), + ), + # trainer + ( + ("--trainer",), + _make_search_query(has_trainer=True), + ), + ( + ("--no-trainer",), + _make_search_query(has_trainer=False), + ), + # alt NT layout + ( + ("--alt-nt",), + _make_search_query(has_alternate_nt=True), + ), + ( + ("--no-alt-nt",), + _make_search_query(has_alternate_nt=False), + ), + # all + ( + ( + "--mapper-id", "4", + "--submapper-id", "0", + "--rom-format", "ines_20", + "--mirroring", "horizontal", + "--console", "nes", + "--tv-system", "ntsc", + "--battery", + "--trainer", + "--alt-nt", + ), + _make_search_query( + mapper_id=4, + submapper_id=0, + rom_format=nes.RomFormat.INES_20, + mirroring=nes.Mirroring.HORIZONTAL, + console_type=nes.ConsoleType.NES, + tv_system=nes.TvSystem.NTSC, + has_battery=True, + has_trainer=True, + has_alternate_nt=True, + ) + ), + ) +) # fmt: skip +def test_search_roms__valid_options( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, + options: Iterable[str], + search_query: nes.SearchQuery, +) -> None: + with _mock_search(monkeypatch, nes.SearchResult([], [])) as mock: + root_dir = "root_dir" + result = cli_runner.invoke( + cli.main, + ["nes", "search", root_dir, *options], + ) + + assert result.exit_code == cli.statuses.ES_SUCCESS + mock.assert_called_once_with(root_dir, query=search_query) + + +# ruff: disable[E501] +@pytest.mark.cli +@pytest.mark.parametrize( + "options,expected_error", + ( + ( + ("--mapper-id", "a"), + "Invalid value for '-m' / '--mapper-id': 'a' is not a valid integer", + ), + ( + ("--submapper-id", "a"), + "Invalid value for '-s' / '--submapper-id': 'a' is not a valid integer", + ), + ( + ("--rom-format", "archaic_ines"), + "Invalid value for '-f' / '--rom-format': 'archaic_ines' is not one of 'ines', 'ines_20'", + ), + ( + ("--mirroring", "hor"), + "Invalid value for '-r' / '--mirroring': 'hor' is not one of 'vertical', 'horizontal'", + ), + ( + ("--console", "default"), + "Invalid value for '-c' / '--console': 'default' is not one of 'nes', 'vs_unisystem', 'playchoice_10'.", + ), + ( + ("--tv-system", "HD"), + "Invalid value for '-v' / '--tv-system': 'HD' is not one of 'ntsc', 'pal', 'dendy', 'multiple_region'", + ), + ( + ("--battery", "yes"), + "Got unexpected extra argument (yes)", + ), + ), +) +# ruff: enable[E501] +# fmt: skip +def test_search_roms__invalid_options( + cli_runner: click.testing.CliRunner, + options: Iterable[str], + expected_error: str, +) -> None: + result = cli_runner.invoke( + cli.main, + ["nes", "search", "root_dir", *options], + ) + assert result.exit_code == cli.statuses.ES_USAGE_ERROR + assert result.stdout == "" + assert expected_error in result.stderr + + +@pytest.mark.cli +def test_search_roms__empty_result_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, +) -> None: + with _mock_search(monkeypatch, nes.SearchResult([], [])): + result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) + + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stderr == "" + assert ( + clear_ansi_escape(result.stdout) == "ROM matches: 0, Errors: 0\n" + ) + + +@pytest.mark.cli +def test_search_roms__mixed_result_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, +) -> None: + rom_info1 = nes.RomInfo( + filepath=Path("rom.nes"), + metadata=nes.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=4, + submapper_id=0, + rom_format=nes.RomFormat.INES_20, + mirroring=nes.Mirroring.HORIZONTAL, + console_type=nes.ConsoleType.NES, + tv_system=nes.TvSystem.NTSC, + has_battery=False, + has_trainer=True, + has_alternate_nt=False, + ), + ) + rom_info2 = nes.RomInfo( + filepath=Path("rom2.nes"), + metadata=nes.RomMetadata( + prg_rom_size=131072, + chr_rom_size=0, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=8192, + chr_nvram_size=0, + mapper_id=2, + submapper_id=2, + rom_format=nes.RomFormat.INES_20, + mirroring=nes.Mirroring.VERTICAL, + console_type=nes.ConsoleType.NES, + tv_system=nes.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ), + ) + search_result = nes.SearchResult( + matches=[rom_info1, rom_info2], + failures=[ + nes.InspectionFailure( + Path("invalid.nes"), + ValueError("invalid header"), + ), + ], + ) + # ruff: disable[E501] + expected_output = textwrap.dedent( + """\ + rom.nes + [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 4 | Horizontal | PRG 16384 | CHR 8192] + rom2.nes + [iNES 2.0 | RP2C02 (NTSC NES) | Mapper 2 | Vertical | PRG 131072 | CHR 0] + invalid.nes + invalid header + ROM matches: 2, Errors: 1 + """ + ) + # ruff: enable[E501] + + with _mock_search(monkeypatch, search_result): + result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) + + assert result.exit_code == cli.statuses.ES_SUCCESS + assert result.stderr == "" + assert clear_ansi_escape(result.stdout) == expected_output + + +@pytest.mark.cli +@pytest.mark.parametrize( + "error", + ( + FileNotFoundError("File not found"), + PermissionError("Cannot access to file"), + NotADirectoryError("Directory name is invalid"), + ), +) +def test_search_roms__expected_failure_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, + error: Exception, +) -> None: + with _mock_search(monkeypatch, error): + result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) + + assert result.exit_code == cli.statuses.ES_ERROR + assert result.stdout == "" + assert clear_ansi_escape(result.stderr) == f"{error}\n" + + +@pytest.mark.cli +@pytest.mark.parametrize( + "error", + ( + RuntimeError("Something went wrong.."), + ValueError("Value error"), + TypeError("Type error"), + AttributeError("Attribute error"), + ), +) +def test_search_roms__unexpected_failure_output( + monkeypatch: pytest.MonkeyPatch, + cli_runner: click.testing.CliRunner, + error: Exception, +) -> None: + with _mock_search(monkeypatch, error): + result = cli_runner.invoke(cli.main, ["nes", "search", "root_dir"]) + + assert result.exit_code == cli.statuses.ES_INTERNAL_ERROR + assert result.stdout == "" + assert clear_ansi_escape(result.stderr) == f"{error}\n" diff --git a/tests/cli/utils.py b/tests/cli/utils.py index 39766c7..4791edf 100644 --- a/tests/cli/utils.py +++ b/tests/cli/utils.py @@ -1,31 +1,31 @@ -import contextlib -import re -from collections.abc import Generator -from types import ModuleType -from unittest.mock import MagicMock, create_autospec - -import pytest - -_ANSI_ESCAPE_REGEXP = re.compile(r"\x1b\[[0-9;]*m") - - -def clear_ansi_escape(value: str) -> str: - return _ANSI_ESCAPE_REGEXP.sub("", value) - - -@contextlib.contextmanager -def mock_app_func( - module: ModuleType, - func_name: str, - monkeypatch: pytest.MonkeyPatch, - return_value: object | Exception, -) -> Generator[MagicMock]: - func = getattr(module, func_name) # pyright: ignore[reportAny] - if isinstance(return_value, Exception): - mock = create_autospec(func, side_effect=return_value) # pyright: ignore[reportAny] - else: - mock = create_autospec(func, return_value=return_value) # pyright: ignore[reportAny] - - with monkeypatch.context() as patcher: - patcher.setattr(module, func_name, mock) # pyright: ignore[reportAny] - yield mock +import contextlib +import re +from collections.abc import Generator +from types import ModuleType +from unittest.mock import MagicMock, create_autospec + +import pytest + +_ANSI_ESCAPE_REGEXP = re.compile(r"\x1b\[[0-9;]*m") + + +def clear_ansi_escape(value: str) -> str: + return _ANSI_ESCAPE_REGEXP.sub("", value) + + +@contextlib.contextmanager +def mock_app_func( + module: ModuleType, + func_name: str, + monkeypatch: pytest.MonkeyPatch, + return_value: object | Exception, +) -> Generator[MagicMock]: + func = getattr(module, func_name) # pyright: ignore[reportAny] + if isinstance(return_value, Exception): + mock = create_autospec(func, side_effect=return_value) # pyright: ignore[reportAny] + else: + mock = create_autospec(func, return_value=return_value) # pyright: ignore[reportAny] + + with monkeypatch.context() as patcher: + patcher.setattr(module, func_name, mock) # pyright: ignore[reportAny] + yield mock diff --git a/tests/core/test_nes_parser.py b/tests/core/test_nes_parser.py index 02d25a1..690d9a5 100644 --- a/tests/core/test_nes_parser.py +++ b/tests/core/test_nes_parser.py @@ -1,406 +1,406 @@ -from io import BytesIO, StringIO -from typing import Any, final - -import pytest - -from romq.core.nes import parser as nes_parser - - -@final -class _NoSeekableStream: - __slots__ = ("_stream",) - - def __init__(self, data: bytes): - self._stream = BytesIO(data) - - def read(self, n: int = -1, /) -> bytes: - return self._stream.read(n) - - -@final -class _NoReadableStream: - __slots__ = ("_stream",) - - def __init__(self, data: bytes): - self._stream = BytesIO(data) - - def seek(self, _offset: int, _whence: int = 0, /) -> int: - return 0 - - def tell(self) -> int: - return 0 - - -@pytest.mark.core -@pytest.mark.parametrize( - "stream", - ( - BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - ), -) # fmt: skip -def test_is_rom__valid_header(stream: BytesIO) -> None: - orig_pos = stream.tell() - assert nes_parser.is_rom(stream) is True - assert stream.tell() == orig_pos - - -@pytest.mark.core -@pytest.mark.parametrize( - "stream", - ( - BytesIO(), - BytesIO(b"NES\x1a"), - BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - ), -) -def test_is_rom__invalid_header(stream: BytesIO) -> None: - orig_pos = stream.tell() - assert nes_parser.is_rom(stream) is False - assert stream.tell() == orig_pos - - -@pytest.mark.core -@pytest.mark.parametrize( - "stream,expected", - ( - # iNES 1.0 basic - ( - BytesIO(b"NES\x1a\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 1.0 max mapper, x2 PRG/CHR banks - ( - BytesIO(b"NES\x1a\x02\x02\xf0\xf0\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=32768, - chr_rom_size=16384, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=255, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 1.0 battery, trainer, vertical - ( - BytesIO(b"NES\x1a\x01\x01\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES, - mirroring=nes_parser.Mirroring.VERTICAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=True, - has_trainer=True, - has_alternate_nt=False, - ) - ), - # iNES 2.0 basic - ( - BytesIO(b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 2.0 max mapper / submapper - ( - BytesIO(b"NES\x1a\x01\x01\xf0\xf8\xff\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=4095, - submapper_id=15, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 2.0 mapper 4, PRG RAM 8K - ( - BytesIO(b"NES\x1a\x08\x10\x40\x08\x00\x00\x07\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=131072, - chr_rom_size=131072, - prg_ram_size=8192, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=4, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 2.0 mapper 2, submapper 2, CHR RAM, vertical - ( - BytesIO(b"NES\x1a\x08\x00\x21\x08\x20\x00\x00\x07\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=131072, - chr_rom_size=0, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=8192, - chr_nvram_size=0, - mapper_id=2, - submapper_id=2, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.VERTICAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 2.0 VS Unisystem, PAL, battery, trainer, alt NT layout - ( - BytesIO(b"NES\x1a\x01\x01\x0e\x09\x00\x00\x00\x00\x01\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.VS_UNISYSTEM, - tv_system=nes_parser.TvSystem.PAL, - has_battery=True, - has_trainer=True, - has_alternate_nt=True, - ) - ), - # iNES 2.0 large PRG ROM (512K), CHR NVRAM, battery - ( - BytesIO(b"NES\x1a\x20\x00\x42\x08\x00\x00\x00\x70\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=524288, - chr_rom_size=0, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=8192, - mapper_id=4, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=True, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 2.0 Dendy - ( - BytesIO(b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x03\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.DENDY, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # iNES 2.0 Exponent PRG size - ( - BytesIO(b"NES\x1a\x51\x01\x00\x08\x00\x0f\x00\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=3145728, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=0, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=False, - has_trainer=False, - has_alternate_nt=False, - ) - ), - # 12. iNES 2.0 PRG NVRAM, battery - ( - BytesIO(b"NES\x1a\x01\x01\x02\x08\x00\x00\x70\x00\x00\x00\x00\x00"), - nes_parser.RomMetadata( - prg_rom_size=16384, - chr_rom_size=8192, - prg_ram_size=0, - prg_nvram_size=8192, - chr_ram_size=0, - chr_nvram_size=0, - mapper_id=0, - submapper_id=0, - rom_format=nes_parser.RomFormat.INES_20, - mirroring=nes_parser.Mirroring.HORIZONTAL, - console_type=nes_parser.ConsoleType.NES, - tv_system=nes_parser.TvSystem.NTSC, - has_battery=True, - has_trainer=False, - has_alternate_nt=False, - ) - ), - ) -) # fmt: skip -# TODO: Split ines 1.0 / 2.0 -def test_parse_stream__valid_header( - stream: BytesIO, - expected: nes_parser.RomMetadata, -) -> None: - orig_pos = stream.tell() - assert nes_parser.parse_stream(stream) == expected - assert stream.tell() == orig_pos - - -@pytest.mark.core -@pytest.mark.parametrize( - "stream,error_type,message_regexp", - ( - ( - BytesIO(b""), - nes_parser.errors.HeaderParseError, - r"ROM header is too small", - ), - ( - BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.errors.HeaderParseError, - r"ROM header is too small", - ), - ( - BytesIO(b"NES\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.errors.InvalidHeaderError, - r"Invalid iNES header ID", - ), - ( - BytesIO(b"NES\x1a\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.errors.UnsupportedRomError, - r"Archaic iNES format is not supported", - ), - ( - BytesIO(b"NES\x1a\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00"), - nes_parser.errors.UnknownRomError, - r"Unknown ROM format bits: 0x03", - ), - ), -) # fmt: skip -def test_parse_stream__invalid_header( - stream: BytesIO, - error_type: type[nes_parser.errors.ParseError], - message_regexp: str, -) -> None: - orig_pos = stream.tell() - - with pytest.raises(error_type, match=message_regexp): - _ = nes_parser.parse_stream(stream) - - assert stream.tell() == orig_pos - - -@pytest.mark.core -@pytest.mark.parametrize( - "stream,error_type,message_regexp", - ( - ( - StringIO("4e45531a010102080000700000000000"), - TypeError, - r"bytes-like object is required", - ), - ( - _NoSeekableStream( - b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x03\x00\x00\x00" - ), - AttributeError, - r"object has no attribute 'tell'", - ), - ( - _NoReadableStream( - b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x03\x00\x00\x00" - ), - AttributeError, - r"object has no attribute 'read'", - ), - ), -) -def test_parse_stream__invalid_stream( - stream: Any, # pyright: ignore[reportExplicitAny, reportAny] - error_type: type[Exception], - message_regexp: str, -) -> None: - with pytest.raises(error_type, match=message_regexp): - _ = nes_parser.parse_stream(stream) # pyright: ignore[reportAny] +from io import BytesIO, StringIO +from typing import Any, final + +import pytest + +from romq.core.nes import parser as nes_parser + + +@final +class _NoSeekableStream: + __slots__ = ("_stream",) + + def __init__(self, data: bytes): + self._stream = BytesIO(data) + + def read(self, n: int = -1, /) -> bytes: + return self._stream.read(n) + + +@final +class _NoReadableStream: + __slots__ = ("_stream",) + + def __init__(self, data: bytes): + self._stream = BytesIO(data) + + def seek(self, _offset: int, _whence: int = 0, /) -> int: + return 0 + + def tell(self) -> int: + return 0 + + +@pytest.mark.core +@pytest.mark.parametrize( + "stream", + ( + BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + ), +) # fmt: skip +def test_is_rom__valid_header(stream: BytesIO) -> None: + orig_pos = stream.tell() + assert nes_parser.is_rom(stream) is True + assert stream.tell() == orig_pos + + +@pytest.mark.core +@pytest.mark.parametrize( + "stream", + ( + BytesIO(), + BytesIO(b"NES\x1a"), + BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + ), +) +def test_is_rom__invalid_header(stream: BytesIO) -> None: + orig_pos = stream.tell() + assert nes_parser.is_rom(stream) is False + assert stream.tell() == orig_pos + + +@pytest.mark.core +@pytest.mark.parametrize( + "stream,expected", + ( + # iNES 1.0 basic + ( + BytesIO(b"NES\x1a\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 1.0 max mapper, x2 PRG/CHR banks + ( + BytesIO(b"NES\x1a\x02\x02\xf0\xf0\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=32768, + chr_rom_size=16384, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=255, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 1.0 battery, trainer, vertical + ( + BytesIO(b"NES\x1a\x01\x01\x07\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES, + mirroring=nes_parser.Mirroring.VERTICAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=True, + has_trainer=True, + has_alternate_nt=False, + ) + ), + # iNES 2.0 basic + ( + BytesIO(b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 2.0 max mapper / submapper + ( + BytesIO(b"NES\x1a\x01\x01\xf0\xf8\xff\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=4095, + submapper_id=15, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 2.0 mapper 4, PRG RAM 8K + ( + BytesIO(b"NES\x1a\x08\x10\x40\x08\x00\x00\x07\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=131072, + chr_rom_size=131072, + prg_ram_size=8192, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=4, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 2.0 mapper 2, submapper 2, CHR RAM, vertical + ( + BytesIO(b"NES\x1a\x08\x00\x21\x08\x20\x00\x00\x07\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=131072, + chr_rom_size=0, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=8192, + chr_nvram_size=0, + mapper_id=2, + submapper_id=2, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.VERTICAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 2.0 VS Unisystem, PAL, battery, trainer, alt NT layout + ( + BytesIO(b"NES\x1a\x01\x01\x0e\x09\x00\x00\x00\x00\x01\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.VS_UNISYSTEM, + tv_system=nes_parser.TvSystem.PAL, + has_battery=True, + has_trainer=True, + has_alternate_nt=True, + ) + ), + # iNES 2.0 large PRG ROM (512K), CHR NVRAM, battery + ( + BytesIO(b"NES\x1a\x20\x00\x42\x08\x00\x00\x00\x70\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=524288, + chr_rom_size=0, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=8192, + mapper_id=4, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=True, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 2.0 Dendy + ( + BytesIO(b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x03\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.DENDY, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # iNES 2.0 Exponent PRG size + ( + BytesIO(b"NES\x1a\x51\x01\x00\x08\x00\x0f\x00\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=3145728, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=0, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=False, + has_trainer=False, + has_alternate_nt=False, + ) + ), + # 12. iNES 2.0 PRG NVRAM, battery + ( + BytesIO(b"NES\x1a\x01\x01\x02\x08\x00\x00\x70\x00\x00\x00\x00\x00"), + nes_parser.RomMetadata( + prg_rom_size=16384, + chr_rom_size=8192, + prg_ram_size=0, + prg_nvram_size=8192, + chr_ram_size=0, + chr_nvram_size=0, + mapper_id=0, + submapper_id=0, + rom_format=nes_parser.RomFormat.INES_20, + mirroring=nes_parser.Mirroring.HORIZONTAL, + console_type=nes_parser.ConsoleType.NES, + tv_system=nes_parser.TvSystem.NTSC, + has_battery=True, + has_trainer=False, + has_alternate_nt=False, + ) + ), + ) +) # fmt: skip +# TODO: Split ines 1.0 / 2.0 +def test_parse_stream__valid_header( + stream: BytesIO, + expected: nes_parser.RomMetadata, +) -> None: + orig_pos = stream.tell() + assert nes_parser.parse_stream(stream) == expected + assert stream.tell() == orig_pos + + +@pytest.mark.core +@pytest.mark.parametrize( + "stream,error_type,message_regexp", + ( + ( + BytesIO(b""), + nes_parser.errors.HeaderParseError, + r"ROM header is too small", + ), + ( + BytesIO(b"NES\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.errors.HeaderParseError, + r"ROM header is too small", + ), + ( + BytesIO(b"NES\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.errors.InvalidHeaderError, + r"Invalid iNES header ID", + ), + ( + BytesIO(b"NES\x1a\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.errors.UnsupportedRomError, + r"Archaic iNES format is not supported", + ), + ( + BytesIO(b"NES\x1a\x00\x00\x00\x0c\x00\x00\x00\x00\x00\x00\x00\x00"), + nes_parser.errors.UnknownRomError, + r"Unknown ROM format bits: 0x03", + ), + ), +) # fmt: skip +def test_parse_stream__invalid_header( + stream: BytesIO, + error_type: type[nes_parser.errors.ParseError], + message_regexp: str, +) -> None: + orig_pos = stream.tell() + + with pytest.raises(error_type, match=message_regexp): + _ = nes_parser.parse_stream(stream) + + assert stream.tell() == orig_pos + + +@pytest.mark.core +@pytest.mark.parametrize( + "stream,error_type,message_regexp", + ( + ( + StringIO("4e45531a010102080000700000000000"), + TypeError, + r"bytes-like object is required", + ), + ( + _NoSeekableStream( + b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x03\x00\x00\x00" + ), + AttributeError, + r"object has no attribute 'tell'", + ), + ( + _NoReadableStream( + b"NES\x1a\x01\x01\x00\x08\x00\x00\x00\x00\x03\x00\x00\x00" + ), + AttributeError, + r"object has no attribute 'read'", + ), + ), +) +def test_parse_stream__invalid_stream( + stream: Any, # pyright: ignore[reportExplicitAny, reportAny] + error_type: type[Exception], + message_regexp: str, +) -> None: + with pytest.raises(error_type, match=message_regexp): + _ = nes_parser.parse_stream(stream) # pyright: ignore[reportAny] From e59d8ddec16372608476a6fa69369fd2566ecb9c Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 08:38:04 +0300 Subject: [PATCH 04/13] Add .editorconfig --- .editorconfig | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..acd0561 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true + +[*.py] +insert_final_newline = true + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false From c8a8734f0102157df6cff3f77ae9085f996544cc Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 08:39:37 +0300 Subject: [PATCH 05/13] Add .python-version --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 From cbbeec9334e64a7ce41d625448f5ed1d68672101 Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 08:40:25 +0300 Subject: [PATCH 06/13] Update .gitignore --- .gitignore | 2 -- 1 file changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index b068391..400e3c0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,3 @@ wheels/ .zed roms/ - -.python-version From 003219bfb04d6dcba9e0a2528d9515b42a6d8c7e Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 08:48:23 +0300 Subject: [PATCH 07/13] Add uv-lock hook --- prek.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/prek.toml b/prek.toml index 98c8805..9eb408c 100644 --- a/prek.toml +++ b/prek.toml @@ -13,6 +13,13 @@ hooks = [ { id = "check-yaml", files = "\\.(yml|yaml)$", groups = ["ci"] }, ] +[[repos]] +repo = "https://github.com/astral-sh/uv-pre-commit" +rev = "0.11.28" +hooks = [ + { id = "uv-lock" }, +] + [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" rev = "v0.15.21" From d25892a0ca0c17113c94f17de53da4d160efbc9d Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 09:06:34 +0300 Subject: [PATCH 08/13] Remove redundant files pattern for check-yaml hook --- prek.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prek.toml b/prek.toml index 9eb408c..1236acb 100644 --- a/prek.toml +++ b/prek.toml @@ -10,7 +10,7 @@ hooks = [ { id = "check-merge-conflict", groups = ["ci"] }, { id = "mixed-line-ending", args=["--fix=lf"], groups = ["ci"] }, { id = "check-toml", groups = ["ci"] }, - { id = "check-yaml", files = "\\.(yml|yaml)$", groups = ["ci"] }, + { id = "check-yaml", groups = ["ci"] }, ] [[repos]] From 209685add3761b79a5701c8ab05ae94b38e10b24 Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 09:27:20 +0300 Subject: [PATCH 09/13] Set indet size for JSON/YAML files --- .editorconfig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.editorconfig b/.editorconfig index acd0561..df10694 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,3 +15,6 @@ indent_style = tab [*.md] trim_trailing_whitespace = false + +[*.{json,yaml,yml}] +indent_size = 2 From 8722718228ac5cd8eb57dcfa27395d42396d60be Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 10:27:08 +0300 Subject: [PATCH 10/13] Setup CI pipelines --- .github/workflows/ci.yml | 52 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 41 +++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a87c3eb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + +permissions: + contents: read + +jobs: + ci: + name: Lint & Test + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version-file: "pyproject.toml" + enable-cache: true + + - name: Install dependencies + run: uv sync --locked --all-extras --dev + + - name: Pre-commit checks + run: uv run prek --all-files --group ci + + - name: Lint + run: uv run ruff check --output-format=github + + - name: Check format + run: uv run ruff format --check + + - name: Static analize + run: uv run basedpyright + + - name: Test + run: uv run pytest + + - name: Minimize uv cache + run: uv cache prune --ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..65df3d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: "Publish release to PyPI" + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + + publish: + name: Publish package + runs-on: ubuntu-latest + + permissions: + id-token: write + + environment: + name: pypi + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version-file: "pyproject.toml" + enable-cache: true + + - name: Build + run: uv build + + - name: Publish + run: uv publish From fc94b8d133a0dab3ad31cae05d96c8501bbfd2c2 Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 10:34:31 +0300 Subject: [PATCH 11/13] Pin uv version --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b4ec685..7aa5b8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,9 @@ dev = [ [project.scripts] romq = "romq.cli.main:main" +[tool.uv] +required-version = ">=0.11.21,<0.12.0" + [tool.basedpyright] include = ["src", "tests"] typeCheckingMode = "recommended" From 9eafeea5fdc72348aa316c75bd980a0a105911c4 Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 11:45:56 +0300 Subject: [PATCH 12/13] Map /proc/meminfo to fake fs when testing --- tests/app/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 03d7245..f13954c 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -1,3 +1,4 @@ +import sys import tempfile from pathlib import Path @@ -13,4 +14,8 @@ def tmp_dir(request: pytest.FixtureRequest, fs: FakeFilesystem) -> Path: request.function.__name__, # pyright: ignore[reportAny] ) _ = fs.create_dir(dir_path) # pyright: ignore[reportUnknownMemberType] + + if sys.platform == "linux": + fs.add_real_file("/proc/meminfo") # pyright: ignore[reportUnknownMemberType, reportUnusedCallResult] + return dir_path From 1811f02d52613c1a877ebe6b7c98a459e47e16ee Mon Sep 17 00:00:00 2001 From: artcom Date: Mon, 13 Jul 2026 12:09:05 +0300 Subject: [PATCH 13/13] Comment hack for py7zr --- tests/app/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/app/conftest.py b/tests/app/conftest.py index f13954c..265abb3 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -16,6 +16,7 @@ def tmp_dir(request: pytest.FixtureRequest, fs: FakeFilesystem) -> Path: _ = fs.create_dir(dir_path) # pyright: ignore[reportUnknownMemberType] if sys.platform == "linux": + # # py7zr uses psutil which reads /proc/meminfo fs.add_real_file("/proc/meminfo") # pyright: ignore[reportUnknownMemberType, reportUnusedCallResult] return dir_path