diff --git a/fix_ruff_docstring_error.py b/fix_ruff_docstring_error.py deleted file mode 100644 index 64dad78..0000000 --- a/fix_ruff_docstring_error.py +++ /dev/null @@ -1,16 +0,0 @@ -from pathlib import Path - -# ruff currently has an issue that causes it to break docstring indentation -# https://github.com/astral-sh/ruff/issues/8430 - - -def main(): - for path in (Path(__file__).parent / 'vapor').iterdir(): - if path.is_file(): - contents = path.read_text() - contents = contents.replace(' ', '\t') - path.write_text(contents) - - -if __name__ == '__main__': - main() diff --git a/poetry.lock b/poetry.lock index 3edb9df..3e51a34 100644 --- a/poetry.lock +++ b/poetry.lock @@ -397,6 +397,7 @@ files = [ [[package]] name = "idna" version = "3.8" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" @@ -405,6 +406,9 @@ files = [ {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.0.0" diff --git a/pyproject.toml b/pyproject.toml index 0492be4..23b989a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "TUI program to check the ProtonDB compatibility of all the games authors = ["TabulateJarl8 "] license = "GPLv3" readme = "README.md" -packages = [{include = "vapor"}] +packages = [{ include = "vapor" }] homepage = "https://tabulate.tech/software/vapor" repository = "https://github.com/TabulateJarl8/vapor" keywords = ["steam", "protondb", "compatibility", "textual", "tui"] @@ -22,11 +22,9 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Typing :: Typed" -] -include = [ - { path = "tests", format = "sdist" }, + "Typing :: Typed", ] +include = [{ path = "tests", format = "sdist" }] [tool.poetry.dependencies] python = "^3.8.1" @@ -81,9 +79,7 @@ exclude_also = [ ] -omit = [ - "*/__main__.py", -] +omit = ["*/__main__.py"] [tool.ruff.lint] @@ -91,11 +87,12 @@ preview = true extend-select = [ # pycodestyle "E", - "W293", - "W292", - "W605", + # Warnings + "W", # Pyflakes "F", + # pydocstyle + "D", # pyupgrade "UP", # flake8-bugbear @@ -128,9 +125,66 @@ extend-select = [ # eradicate "ERA", # perflint - "PERF" + "PERF", + # flake8-2020 + "YTT", + # flake8-annotations + "ANN", + # flake8-async + "ASYNC", + # flake8-builtins + "A", + # flake8-commas + "COM", + # flake8-implicit-str-concat + "ISC", + # flake8-print + "T20", + # flake8-pytest-style + "PT", + # flake8-use-pathlib + "PTH", + # pylint + "PL", + # tryceratops + "TRY", + # refurb + "FURB", + # pydoclint + "DOC", + # ruff rules + "RUF", ] -ignore = ["E501", "E274", "S110", "FBT001", "FBT002", "PERF203", "S101"] + +ignore = [ + # complains about tab indentation + "W191", + "D206", + # adds a line break before a class docstring + "D203", + # puts the first line summary of a docstring on a different line than the """ + "D213", + # tries to add a blank line after the last docstring section + "D413", + # yells at you if you use a bool typed function argument + "FBT001", + "FBT002", + # yells at you for using try-except in a for loop + "PERF203", + # allow for the use of Any + "ANN401", + # false positives for overriding methods (i think) + "PLR6301", + # disable too many branches check + "PLR0912", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "ANN001", "ANN002", "PLC2701", "ARG002", "PLR2004", "DOC"] +"vapor/main.py" = ["DOC402"] + +[tool.ruff.lint.pydocstyle] +convention = "google" [tool.ruff.format] quote-style = "single" diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..896a252 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""__init__.py for the AUR.""" diff --git a/tests/test_api_interface.py b/tests/test_api_interface.py index c2497e0..a12b2b7 100644 --- a/tests/test_api_interface.py +++ b/tests/test_api_interface.py @@ -1,3 +1,5 @@ +"""Tests related to vapor's API interface.""" + import json from unittest.mock import patch @@ -5,9 +7,9 @@ from vapor.api_interface import ( Response, + _extract_game_is_native, check_game_is_native, parse_anti_cheat_data, - parse_steam_game_platform_info, parse_steam_user_games, ) from vapor.data_structures import AntiCheatStatus, Game @@ -29,23 +31,35 @@ 'games': [ {'appid': 123456, 'name': 'Test Game 1', 'playtime_forever': 100}, {'appid': 789012, 'name': 'Test Game 2', 'playtime_forever': 200}, - ] - } + ], + }, } STEAM_GAME_PLATFORM_DATA = { '123': { 'success': True, 'data': {'platforms': {'windows': True, 'mac': False, 'linux': False}}, - } + }, + '456': { + 'success': True, + 'data': {'platforms': {'windows': False, 'mac': True, 'linux': True}}, + }, } class MockCache: - def __init__(self, has_game: bool): + """Mock Cache object with a set Game data.""" + + def __init__(self, has_game: bool) -> None: + """Construct a new MockCache object.""" self.has_game_cache = has_game - def get_game_data(self, app_id): # noqa: ARG002 + def get_game_data(self, app_id: None) -> Game: + """Return a set Game data for testing. + + Args: + app_id (None): unused argument. + """ return Game( name='Euro Truck Simulator 2', rating='native', @@ -53,20 +67,24 @@ def get_game_data(self, app_id): # noqa: ARG002 app_id='227300', ) - def update_cache(self, game_list): - pass + def update_cache(self, game_list: None) -> None: + """Update cache with dummy function. Does nothing. + Args: + game_list (None): unused argument. + """ -@pytest.mark.asyncio -async def test_parse_steam_game_data(): - assert await parse_steam_game_platform_info(STEAM_GAME_DATA, '123456') - assert not await parse_steam_game_platform_info(STEAM_GAME_DATA, '789012') - assert not await parse_steam_game_platform_info(STEAM_GAME_DATA, '123') +def test_parse_steam_game_data() -> None: + """Test that Steam data is correctly parsed.""" + assert _extract_game_is_native(STEAM_GAME_DATA, '123456') + assert not _extract_game_is_native(STEAM_GAME_DATA, '789012') + assert not _extract_game_is_native(STEAM_GAME_DATA, '123') -@pytest.mark.asyncio -async def test_parse_anti_cheat_data(): - result = await parse_anti_cheat_data(ANTI_CHEAT_DATA) + +def test_parse_anti_cheat_data() -> None: + """Test that anti-cheat data is parsed correctly.""" + result = parse_anti_cheat_data(ANTI_CHEAT_DATA) assert len(result) == 2 assert result[0].app_id == '123456' assert result[0].status == AntiCheatStatus.DENIED @@ -74,7 +92,8 @@ async def test_parse_anti_cheat_data(): @pytest.mark.asyncio -async def test_parse_steam_user_games(): +async def test_parse_steam_user_games() -> None: + """Test that Steam games are parsed correctly.""" with patch( 'vapor.api_interface.get_game_average_rating', return_value='gold', @@ -86,16 +105,27 @@ async def test_parse_steam_user_games(): @pytest.mark.asyncio -async def test_parse_steam_user_priv_acct(): +async def test_parse_steam_user_priv_acct() -> None: + """Test that Steam private accounts are handled correctly.""" cache = MockCache(has_game=True) with pytest.raises(PrivateAccountError): await parse_steam_user_games({'response': {}}, cache) # type: ignore @pytest.mark.asyncio -async def test_check_game_is_native(): +async def test_check_game_is_native() -> None: + """Test that native games are correctly detected and errors are handled.""" with patch( 'vapor.api_interface.async_get', return_value=Response(json.dumps(STEAM_GAME_PLATFORM_DATA), 200), ): assert not await check_game_is_native('123') + assert await check_game_is_native('456') + + with patch( + 'vapor.api_interface.async_get', + return_value=Response(json.dumps(STEAM_GAME_PLATFORM_DATA), 401), + ): + # this should say false even though 456 is native because it + # should fail with a non-200 status code + assert not await check_game_is_native('456') diff --git a/tests/test_arg_parsing.py b/tests/test_arg_parsing.py index d7b4430..d380d07 100644 --- a/tests/test_arg_parsing.py +++ b/tests/test_arg_parsing.py @@ -1,10 +1,12 @@ +"""Tests related to argument parsing.""" + import argparse from unittest.mock import MagicMock, patch from vapor.argument_handler import parse_args -def test_parse_args_without_clear_cache(): +def test_parse_args_without_clear_cache() -> None: """Test parsing arguments without --clear-cache flag.""" with patch( 'argparse.ArgumentParser.parse_args', @@ -15,7 +17,7 @@ def test_parse_args_without_clear_cache(): @patch('pathlib.Path.unlink', **{'other.side_effect': FileNotFoundError}) -def test_parse_args_missing_cache(mock_unlink): +def test_parse_args_missing_cache(mock_unlink) -> None: """Test parsing arguments when cache file is missing.""" with patch( 'argparse.ArgumentParser.parse_args', diff --git a/tests/test_cache.py b/tests/test_cache.py index 455726b..a679410 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,8 +1,11 @@ +"""Tests related to caching.""" + import io import json from datetime import datetime, timedelta import pytest +from typing_extensions import Self from vapor.cache_handler import Cache from vapor.data_structures import AntiCheatData, AntiCheatStatus, Game @@ -11,32 +14,39 @@ class BytesIOPath: """A Path-like object that writes to a BytesIO object instead of the filesystem.""" - def __init__(self, bytes_io): + def __init__(self, bytes_io: io.BytesIO) -> None: + """Construct a BytesIOPath object.""" self.bytes_io = bytes_io - def read_text(self): + def read_text(self) -> str: + """Seek to 0 and return the reading of the BytesIO.""" self.bytes_io.seek(0) return self.bytes_io.read().decode() - def write_text(self, text): + def write_text(self, text: str) -> None: + """Write text to the BytesIO object.""" self.bytes_io.seek(0) self.bytes_io.truncate() self.bytes_io.write(text.encode()) - def __enter__(self): + def __enter__(self) -> Self: + """Return self.""" return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type, exc_value, traceback) -> None: + """Close the BytesIO object.""" self.bytes_io.close() @pytest.fixture -def cache(): +def cache() -> Cache: + """Fixture for the Cache object.""" return Cache() @pytest.fixture -def cache_data(): +def cache_data() -> dict: + """Fixture for getting the cache data.""" return { 'game_cache': { '123456': { @@ -44,7 +54,7 @@ def cache_data(): 'rating': 'gold', 'playtime': 100, 'timestamp': (datetime.now() - timedelta(days=8)).strftime( - '%Y-%m-%d %H:%M:%S' + '%Y-%m-%d %H:%M:%S', ), }, '483': { @@ -52,25 +62,27 @@ def cache_data(): 'rating': 'platinum', 'playtime': 100, 'timestamp': (datetime.now() - timedelta(days=1)).strftime( - '%Y-%m-%d %H:%M:%S' + '%Y-%m-%d %H:%M:%S', ), }, }, 'anticheat_cache': { 'data': {'789012': 'Denied'}, 'timestamp': (datetime.now() - timedelta(days=8)).strftime( - '%Y-%m-%d %H:%M:%S' + '%Y-%m-%d %H:%M:%S', ), }, } -def test_cache_properties_without_loading(cache): +def test_cache_properties_without_loading(cache: Cache) -> None: + """Test that Cache properties are correctly before cache has been loaded.""" assert not cache.has_game_cache assert not cache.has_anticheat_cache -def test_load_cache(cache, cache_data): +def test_load_cache(cache, cache_data) -> None: + """Test that the Cache loads data correctly.""" with io.BytesIO(json.dumps(cache_data).encode()) as f: cache.cache_path = BytesIOPath(f) cache.load_cache(prune=False) @@ -83,7 +95,8 @@ def test_load_cache(cache, cache_data): assert cache.get_anticheat_data('0') is None -def test_loading_bad_file(cache): +def test_loading_bad_file(cache) -> None: + """Test that Cache behaves properly when a bad file is loaded.""" cache.cache_path = '' cache_before = cache @@ -92,7 +105,8 @@ def test_loading_bad_file(cache): assert cache == cache_before -def test_prune_bad_file(cache): +def test_prune_bad_file(cache) -> None: + """Test that pruning a bad file doesn't crash.""" cache.cache_path = '' cache_before = cache @@ -101,7 +115,8 @@ def test_prune_bad_file(cache): assert cache == cache_before -def test_invalid_datetimes(cache, cache_data): +def test_invalid_datetimes(cache, cache_data) -> None: + """Test that invalid datetimes are handled correctly.""" cache_data['game_cache']['999'] = { 'name': 'invalid datetime game', 'rating': 'platinum', @@ -122,7 +137,8 @@ def test_invalid_datetimes(cache, cache_data): assert 'anticheat_cache' not in updated_data -def test_update_cache(cache, cache_data): +def test_update_cache(cache, cache_data) -> None: + """Test that cache updates are performed correctly.""" with io.BytesIO(json.dumps(cache_data).encode()) as f: cache.cache_path = BytesIOPath(f) cache.update_cache( @@ -131,7 +147,7 @@ def test_update_cache(cache, cache_data): Game(name='Game 2', rating='silver', playtime=200, app_id='483'), ], anti_cheat_list=[ - AntiCheatData(app_id='987654', status=AntiCheatStatus.DENIED) + AntiCheatData(app_id='987654', status=AntiCheatStatus.DENIED), ], ) @@ -146,7 +162,8 @@ def test_update_cache(cache, cache_data): ) -def test_prune_cache(cache, cache_data): +def test_prune_cache(cache, cache_data) -> None: + """Test that cache prunes are performed correctly.""" with io.BytesIO(json.dumps(cache_data).encode()) as f: cache.cache_path = BytesIOPath(f) cache.load_cache(prune=True) diff --git a/tests/test_config.py b/tests/test_config.py index 7e50c11..1b41f92 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,85 +1,106 @@ +"""Tests related to vapor's config handler.""" + from io import BytesIO import pytest +from typing_extensions import Self from vapor.config_handler import Config from vapor.exceptions import ConfigFileNotReadError, ConfigReadError, ConfigWriteError class InMemoryPath(BytesIO): - def __init__(self): + """An in memory "file" with a virtual path.""" + + def __init__(self) -> None: + """Construct a virtual in-memory file.""" super().__init__() self.write(b'') self.exists_bool = True - def open(self, _): + def open(self, _) -> Self: + """Seek to 0 to mimic file opening.""" self.seek(0) return self - def exists(self): + def exists(self) -> bool: + """Return whether or not the file has been set to exist by the user.""" return self.exists_bool - def write(self, string): + def write(self, string) -> int: + """Write a string to the virtual file.""" if isinstance(string, str): string = string.encode() super().write(string) - def __exit__(self, *_): - pass + return 0 + + def __exit__(self, *_) -> None: + """Define dummy method for use in with blocks.""" @pytest.fixture -def config(): +def config() -> Config: + """Pytest fixture of Config that uses InMemoryPath.""" cfg = Config() cfg._config_path = InMemoryPath() # type: ignore return cfg -def test_set_value(config): +def test_set_value(config: Config) -> None: + """Test setting a value in the config.""" config.read_config() config.set_value('test_key', 'test_value') assert config.get_value('test_key') == 'test_value' -def test_set_value_no_read(config): +def test_set_value_no_read(config: Config) -> None: + """Test that setting a value without reading throws an error.""" with pytest.raises(ConfigFileNotReadError): config.set_value('test', 'test2') -def test_get_value_no_read(config): - assert config.get_value('non_existent_key') == '' +def test_get_value_no_read(config: Config) -> None: + """Test getting a value without returns an empty string.""" + assert not config.get_value('non_existent_key') -def test_get_value_empty(config): +def test_get_value_empty(config: Config) -> None: + """Test that getting a nonexistant value behaves correctly.""" config.read_config() - assert config.get_value('non_existent_key') == '' + assert not config.get_value('non_existent_key') -def test_write_config(config): +def test_write_config(config) -> None: + """Test that writing the config works correctly.""" config.read_config() config.set_value('test_key', 'test_value') config.write_config() assert config._config_path.getvalue() != b'' -def test_write_config_no_read(config): +def test_write_config_no_read(config: Config) -> None: + """Test writing config without reading throws an error.""" with pytest.raises(ConfigFileNotReadError): config.write_config() -def test_read_config_os_error(config): +def test_read_config_os_error(config) -> None: + """Test reading with an invalid path throws an error.""" config._config_path = '' with pytest.raises(ConfigReadError): config.read_config() -def test_read_config_non_existent_file(config): +def test_read_config_non_existent_file(config) -> None: + """Test reading when file doesn't exist behaves correctly.""" config._config_path.exists_bool = False assert config.read_config()._config_data._sections == {} -def test_write_config_non_existent_file(config): +def test_write_config_non_existent_file(config) -> None: + """Test that writing to a nonexistant path throws an error.""" config.read_config() config._config_path = '' with pytest.raises(ConfigWriteError): diff --git a/tests/test_data_structures.py b/tests/test_data_structures.py index 789678b..48f79d9 100644 --- a/tests/test_data_structures.py +++ b/tests/test_data_structures.py @@ -1,7 +1,10 @@ +"""Tests realted to vapor's data structures.""" + from vapor.data_structures import _ANTI_CHEAT_COLORS, AntiCheatData, AntiCheatStatus -def test_anti_cheat_data_color_resolution(): +def test_anti_cheat_data_color_resolution() -> None: + """Test that anticheat colors are correct.""" assert ( AntiCheatData('', AntiCheatStatus.BROKEN).color == _ANTI_CHEAT_COLORS['Broken'] ) diff --git a/tests/test_ui.py b/tests/test_ui.py index 4daaba1..302eb7f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,3 +1,6 @@ +"""Vapor UI tests.""" + +from typing import Optional from unittest.mock import Mock, patch import pytest @@ -32,30 +35,36 @@ @pytest.fixture -def config(): +def config() -> Config: + """Pytest fixture of Config that uses InMemoryPath.""" cfg = Config() cfg._config_path = InMemoryPath() # type: ignore return cfg class MockCache: - def get_anticheat_data(self, id): - if id == '123': + """Mock Cache object with set anticheat data.""" + + def get_anticheat_data(self, app_id: str) -> Optional[AntiCheatData]: + """Return anticheat status denied for id 123.""" + if app_id == '123': return AntiCheatData('123', AntiCheatStatus.DENIED) return None @pytest.mark.asyncio -async def test_first_startup(config): +async def test_first_startup(config: Config) -> None: + """Test the UI at first startup.""" app = SteamApp(config) async with app.run_test() as _: - assert app.query_one('#api-key').value == '' # type: ignore - assert app.query_one('#user-id').value == '' # type: ignore - assert app.query_one(DataTable).get_cell_at(Coordinate(0, 0)) == '' + assert not app.query_one('#api-key').value # type: ignore + assert not app.query_one('#user-id').value # type: ignore + assert not app.query_one(DataTable).get_cell_at(Coordinate(0, 0)) @pytest.mark.asyncio -async def test_invalid_input_data(config): +async def test_invalid_input_data(config: Config) -> None: + """Test that inputs correctly respond to invalid input.""" app = SteamApp(config) async with app.run_test() as pilot: @@ -81,7 +90,8 @@ async def test_invalid_input_data(config): @pytest.mark.asyncio -async def test_valid_input_data(config): +async def test_valid_input_data(config: Config) -> None: + """Test that inputs correctly respond to valid data.""" app = SteamApp(config) async with app.run_test() as pilot: @@ -106,16 +116,18 @@ async def test_valid_input_data(config): ) -def test_create_app(): - """This is to cover the default class instantiation with the default Config""" +def test_create_app() -> None: + """Test that app can be created successfully.""" with patch('vapor.config_handler.Config.read_config', return_value=True): SteamApp() @pytest.mark.asyncio -async def test_table_population_username(config): +async def test_table_population_username(config: Config) -> None: + """Test that table is populated correctly on submission.""" with patch('vapor.main.get_anti_cheat_data', return_value=MockCache()), patch( - 'vapor.main.get_steam_user_data', return_value=STEAM_USER_DATA + 'vapor.main.get_steam_user_data', + return_value=STEAM_USER_DATA, ): app = SteamApp(config) @@ -136,7 +148,8 @@ async def test_table_population_username(config): table = app.query_one(DataTable) assert table.get_cell_at(Coordinate(0, 0)) == 'Cool Game' assert table.get_cell_at(Coordinate(0, 1)) == Text( - 'Gold', RATING_DICT['gold'][1] + 'Gold', + RATING_DICT['gold'][1], ) assert table.get_cell_at(Coordinate(0, 2)) == Text('Denied', 'red') @@ -149,9 +162,11 @@ async def test_table_population_username(config): @pytest.mark.asyncio -async def test_parse_steam_url_id(config): +async def test_parse_steam_url_id(config: Config) -> None: + """Test that Steam URLs (/id/) are correctly parsed.""" with patch('vapor.main.get_anti_cheat_data', return_value=MockCache()), patch( - 'vapor.main.get_steam_user_data', return_value=STEAM_USER_DATA + 'vapor.main.get_steam_user_data', + return_value=STEAM_USER_DATA, ): app = SteamApp(config) @@ -168,9 +183,11 @@ async def test_parse_steam_url_id(config): @pytest.mark.asyncio -async def test_parse_steam_url_profiles(config): +async def test_parse_steam_url_profiles(config: Config) -> None: + """Test that Steam URLs (/profiles/) are correctly parsed.""" with patch('vapor.main.get_anti_cheat_data', return_value=MockCache()), patch( - 'vapor.main.get_steam_user_data', return_value=STEAM_USER_DATA + 'vapor.main.get_steam_user_data', + return_value=STEAM_USER_DATA, ): app = SteamApp(config) @@ -188,9 +205,11 @@ async def test_parse_steam_url_profiles(config): @pytest.mark.asyncio -async def test_no_cache_user_query(config): +async def test_no_cache_user_query(config: Config) -> None: + """Test that anticheat data is not present without cache.""" with patch('vapor.main.get_anti_cheat_data', return_value=None), patch( - 'vapor.main.get_steam_user_data', return_value=STEAM_USER_DATA + 'vapor.main.get_steam_user_data', + return_value=STEAM_USER_DATA, ): app = SteamApp(config) @@ -203,15 +222,17 @@ async def test_no_cache_user_query(config): @pytest.mark.asyncio -async def test_user_id_preservation(config): +async def test_user_id_preservation(config: Config) -> None: + """Test that user ID is preserved when the setting is on.""" with patch('vapor.main.get_anti_cheat_data', return_value=None), patch( - 'vapor.main.get_steam_user_data', return_value=STEAM_USER_DATA + 'vapor.main.get_steam_user_data', + return_value=STEAM_USER_DATA, ): app = SteamApp(config) async with app.run_test() as pilot: # make sure theres no username by default - assert app.config.get_value('user-id') == '' + assert not app.config.get_value('user-id') # set the preserve user id config option app.config.set_value('preserve-user-id', 'true') @@ -228,9 +249,11 @@ async def test_user_id_preservation(config): @pytest.mark.asyncio -async def test_invalid_id_error(config): +async def test_invalid_id_error(config: Config) -> None: + """Test that an error is appropriately displayed when an invalid ID is entered.""" with patch('vapor.main.get_anti_cheat_data', return_value=None), patch( - 'vapor.main.get_steam_user_data', side_effect=Mock(side_effect=InvalidIDError) + 'vapor.main.get_steam_user_data', + side_effect=Mock(side_effect=InvalidIDError), ): app = SteamApp(config) @@ -243,7 +266,8 @@ async def test_invalid_id_error(config): @pytest.mark.asyncio -async def test_unauthorized_error(config): +async def test_unauthorized_error(config: Config) -> None: + """Test that invalid Steam API keys display the appropriate error.""" with patch('vapor.main.get_anti_cheat_data', return_value=None), patch( 'vapor.main.get_steam_user_data', side_effect=Mock(side_effect=UnauthorizedError), @@ -259,7 +283,8 @@ async def test_unauthorized_error(config): @pytest.mark.asyncio -async def test_private_account_screen(config): +async def test_private_account_screen(config: Config) -> None: + """Test that the private account error screen shows.""" with patch('vapor.main.get_anti_cheat_data', return_value=None), patch( 'vapor.main.get_steam_user_data', side_effect=Mock(side_effect=PrivateAccountError), @@ -281,11 +306,12 @@ async def test_private_account_screen(config): @pytest.mark.asyncio -async def test_settings_screen(config): +async def test_settings_screen(config: Config) -> None: + """Test that the settings screen works.""" app = SteamApp(config) # check that theres no default value - assert app.config.get_value('preserve-user-id') == '' + assert not app.config.get_value('preserve-user-id') async with app.run_test(size=(105, 24)) as pilot: # open the settings screen diff --git a/vapor/__init__.py b/vapor/__init__.py index e69de29..cbdc171 100644 --- a/vapor/__init__.py +++ b/vapor/__init__.py @@ -0,0 +1,7 @@ +"""TUI program to check the ProtonDB compatibility of all the games of a Steam user. + +Vapor is a Python package built on Textual which offers a simple Terminal User +Interface for checking ProtonDB compatibility ratings of games in a Steam +user's library. The tool seamlessly integrates Steam and ProtonDB APIs +to provide insightful compatibility information. +""" diff --git a/vapor/__main__.py b/vapor/__main__.py index 1419b75..157cf2e 100644 --- a/vapor/__main__.py +++ b/vapor/__main__.py @@ -1,8 +1,17 @@ +"""TUI program to check the ProtonDB compatibility of all the games of a Steam user. + +Vapor is a Python package built on Textual which offers a simple Terminal User +Interface for checking ProtonDB compatibility ratings of games in a Steam +user's library. The tool seamlessly integrates Steam and ProtonDB APIs +to provide insightful compatibility information. +""" + from vapor import main as entrypoint from vapor.argument_handler import parse_args -def main(): +def main() -> None: + """Entrypoint for the program.""" parse_args() app = entrypoint.SteamApp() diff --git a/vapor/api_interface.py b/vapor/api_interface.py index 523223e..31716e6 100644 --- a/vapor/api_interface.py +++ b/vapor/api_interface.py @@ -1,3 +1,5 @@ +"""Steam and ProtonDB API helper functions.""" + import json from typing import Any, Dict, List, Optional @@ -5,7 +7,12 @@ from vapor.cache_handler import Cache from vapor.data_structures import ( + HTTP_BAD_REQUEST, + HTTP_FORBIDDEN, + HTTP_SUCCESS, + HTTP_UNAUTHORIZED, RATING_DICT, + STEAM_USER_ID_LENGTH, AntiCheatData, AntiCheatStatus, Game, @@ -26,7 +33,7 @@ async def async_get(url: str, **session_kwargs: Any) -> Response: Response: A Response object containing the body and status code. """ async with aiohttp.ClientSession(**session_kwargs) as session, session.get( - url + url, ) as response: return Response(data=await response.text(), status=response.status) @@ -43,17 +50,16 @@ async def check_game_is_native(app_id: str) -> bool: data = await async_get( f'https://store.steampowered.com/api/appdetails?appids={app_id}&filters=platforms', ) - if data.status != 200: + if data.status != HTTP_SUCCESS: return False json_data = json.loads(data.data) - return await parse_steam_game_platform_info(json_data, app_id) + return _extract_game_is_native(json_data, app_id) -async def parse_steam_game_platform_info(data: Dict, app_id: str) -> bool: - """Parse data from the Steam API and return whether or not the game is - native to Linux. +def _extract_game_is_native(data: Dict, app_id: str) -> bool: + """Extract whether or not a game is Linux native from API data. Args: data (Dict): the data from the Steam API. @@ -67,13 +73,15 @@ async def parse_steam_game_platform_info(data: Dict, app_id: str) -> bool: json_data = data[str(app_id)] return json_data.get('success', False) and json_data['data']['platforms'].get( - 'linux', False + 'linux', + False, ) async def get_anti_cheat_data() -> Optional[Cache]: - """Get's the anti-cheat data from cache. If expired, it will fetch new - data and write that to cache. + """Get the anti-cheat data from cache. + + If expired, this function will fetch new data and write that to cache. Returns: Optional[Cache]: The cache containing anti-cheat data. @@ -86,7 +94,7 @@ async def get_anti_cheat_data() -> Optional[Cache]: 'https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/master/games.json', ) - if data.status != 200: + if data.status != HTTP_SUCCESS: return None try: @@ -94,16 +102,15 @@ async def get_anti_cheat_data() -> Optional[Cache]: except json.JSONDecodeError: return None - deserialized_data = await parse_anti_cheat_data(anti_cheat_data) + deserialized_data = parse_anti_cheat_data(anti_cheat_data) cache.update_cache(anti_cheat_list=deserialized_data) return cache -async def parse_anti_cheat_data(data: List[Dict]) -> List[AntiCheatData]: - """Parse data from AreWeAntiCheatYet and return a list of - AntiCheatData instances. +def parse_anti_cheat_data(data: List[Dict]) -> List[AntiCheatData]: + """Parse and return data from AreWeAntiCheatYet. Args: data (List[Dict]): The data from AreWeAntiCheatYet @@ -125,7 +132,7 @@ async def get_game_average_rating(app_id: str, cache: Cache) -> str: """Get the average game rating from ProtonDB. Args: - id (str): The game ID. + app_id (str): The game ID. cache (Cache): The game cache. Returns: @@ -140,9 +147,9 @@ async def get_game_average_rating(app_id: str, cache: Cache) -> str: return 'native' data = await async_get( - f'https://www.protondb.com/api/v1/reports/summaries/{app_id}.json' + f'https://www.protondb.com/api/v1/reports/summaries/{app_id}.json', ) - if data.status != 200: + if data.status != HTTP_SUCCESS: return 'pending' json_data = json.loads(data.data) @@ -168,7 +175,7 @@ async def resolve_vanity_name(api_key: str, name: str) -> str: f'https://api.steampowered.com/ISteamUser/ResolveVanityURL/v0001/?key={api_key}&vanityurl={name}', ) - if data.status == 403: + if data.status == HTTP_FORBIDDEN: raise UnauthorizedError user_data = json.loads(data.data) @@ -178,12 +185,12 @@ async def resolve_vanity_name(api_key: str, name: str) -> str: return user_data['response']['steamid'] -async def get_steam_user_data(api_key: str, id: str) -> SteamUserData: +async def get_steam_user_data(api_key: str, user_id: str) -> SteamUserData: """Fetch a steam user's games and get their ratings from ProtonDB. Args: api_key (str): Steam API key. - id (str): The user's Steam ID or vanity name. + user_id (str): The user's Steam ID or vanity name. Raises: InvalidIDError: If an invalid Steam ID is provided. @@ -193,9 +200,9 @@ async def get_steam_user_data(api_key: str, id: str) -> SteamUserData: SteamUserData: The Steam user's data. """ # check if ID is a Steam ID or vanity URL - if len(id) != 17 or not id.startswith('76561198'): + if len(user_id) != STEAM_USER_ID_LENGTH or not user_id.startswith('76561198'): try: - id = await resolve_vanity_name(api_key, id) + user_id = await resolve_vanity_name(api_key, user_id) except UnauthorizedError as e: raise UnauthorizedError from e except InvalidIDError: @@ -204,11 +211,11 @@ async def get_steam_user_data(api_key: str, id: str) -> SteamUserData: cache = Cache().load_cache() data = await async_get( - f'http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={api_key}&steamid={id}&format=json&include_appinfo=1&include_played_free_games=1', + f'http://api.steampowered.com/IPlayerService/GetOwnedGames/v0001/?key={api_key}&steamid={user_id}&format=json&include_appinfo=1&include_played_free_games=1', ) - if data.status == 400: + if data.status == HTTP_BAD_REQUEST: raise InvalidIDError - if data.status == 401: + if data.status == HTTP_UNAUTHORIZED: raise UnauthorizedError data = json.loads(data.data) @@ -228,8 +235,11 @@ async def parse_steam_user_games( Returns: SteamUserData: the user's Steam games and ProtonDB ratings - """ + Raises: + PrivateAccountError: if `games` is not present in `data['response']` + (the user's account was found but is private) + """ data = data['response'] if 'games' not in data: @@ -258,7 +268,8 @@ async def parse_steam_user_games( if cache.get_game_data(game.app_id) is not None ] - # we do this in a seperate loop so that we're not mutating the iterable during iteration + # we do this in a seperate loop so that we're not mutating the + # iterable during iteration for game in games_to_remove: game_ratings_copy.remove(game) @@ -268,8 +279,8 @@ async def parse_steam_user_games( # compute user average game_rating_nums = [RATING_DICT[game.rating][0] for game in game_ratings] user_average = round(sum(game_rating_nums) / len(game_rating_nums)) - user_average_text = [ + user_average_text = next( key for key, value in RATING_DICT.items() if value[0] == user_average - ][0] + ) return SteamUserData(game_ratings=game_ratings, user_average=user_average_text) diff --git a/vapor/argument_handler.py b/vapor/argument_handler.py index 9150b95..2639992 100644 --- a/vapor/argument_handler.py +++ b/vapor/argument_handler.py @@ -1,16 +1,21 @@ +"""Vapor argument parsing.""" + import argparse from vapor import cache_handler -def parse_args(): +def parse_args() -> None: """Parse arguments from stdin.""" parser = argparse.ArgumentParser( prog='vapor', - description='TUI program to check the ProtonDB compatibility of all the games of a Steam user', + description=( + 'TUI program to check the ProtonDB' + ' compatibility of all the games of a Steam user' + ), ) parser.add_argument( - '--clear-cache', action='store_true', help="Clear all of vapor's cache" + '--clear-cache', action='store_true', help="Clear all of vapor's cache", ) args = parser.parse_args() diff --git a/vapor/cache_handler.py b/vapor/cache_handler.py index b5af125..148f43a 100644 --- a/vapor/cache_handler.py +++ b/vapor/cache_handler.py @@ -1,3 +1,5 @@ +"""Vapor cache handling.""" + import json from datetime import datetime from typing import Dict, List, Optional, Tuple @@ -17,13 +19,20 @@ class Cache: - def __init__(self): + """Cache wrapper class. + + Includes methods to aid with loading, updating, pruning, etc. + """ + + def __init__(self) -> None: + """Construct a new Cache object.""" self.cache_path = CACHE_PATH self._games_data: Dict[str, Tuple[Game, str]] = {} self._anti_cheat_data: Dict[str, AntiCheatData] = {} self._anti_cheat_timestamp: str = '' - def __repr__(self): + def __repr__(self) -> str: + """Return the string representation of the Cache object.""" return f'Cache({self.__dict__!r})' def _serialize_game_data(self) -> dict: @@ -95,11 +104,12 @@ def get_anticheat_data(self, app_id: str) -> Optional[AntiCheatData]: return None - def load_cache(self, prune=True) -> Self: + def load_cache(self, prune: Optional[bool] = True) -> Self: """Load and deserialize the cache. Args: - prune (bool, optional): Whether or not to prune old cache entries. Defaults to True. + prune (bool, optional): Whether or not to prune old cache + entries. Defaults to True. Returns: Self: self. @@ -143,8 +153,10 @@ def update_cache( """Update the cache file with new game and anticheat data. Args: - game_list (Optional[List[Game]], optional): List of new game data. Defaults to None. - anti_cheat_list (Optional[List[AntiCheatData]], optional): List of new anticheat data. Defaults to None. + game_list (Optional[List[Game]], optional): List of new game data. + Defaults to None. + anti_cheat_list (Optional[List[AntiCheatData]], optional): List of new + anticheat data. Defaults to None. Returns: Self: self. @@ -205,7 +217,7 @@ def prune_cache(self) -> Self: if 'anticheat_cache' in data: try: parsed_date = datetime.strptime( - data['anticheat_cache']['timestamp'], TIMESTAMP_FORMAT + data['anticheat_cache']['timestamp'], TIMESTAMP_FORMAT, ) if (datetime.now() - parsed_date).days > CACHE_INVALIDATION_DAYS: # cache is too old, delete game diff --git a/vapor/config_handler.py b/vapor/config_handler.py index 238dd47..5de428a 100644 --- a/vapor/config_handler.py +++ b/vapor/config_handler.py @@ -1,3 +1,5 @@ +"""Vapor configuration file handling.""" + from configparser import ConfigParser from typing import Optional @@ -11,12 +13,18 @@ class Config: - def __init__(self): + """Config wrapper class. + + Includes methods to aid with reading and writing, setting and getting, etc. + """ + + def __init__(self) -> None: + """Construct a new Config object.""" self._config_path = CONFIG_PATH self._config_data: Optional[ConfigParser] = None def set_value(self, key: str, value: str) -> Self: - """Sets a value in the config file. + """Set a value in the config file. This does not write to the actual config file, just updates it in memory. @@ -28,7 +36,8 @@ def set_value(self, key: str, value: str) -> Self: Self Raises: - ConfigFileNotReadError: If a config value is set without the config being read. + ConfigFileNotReadError: If a config value is set without the + config being read. """ if self._config_data is None: raise ConfigFileNotReadError @@ -41,7 +50,7 @@ def set_value(self, key: str, value: str) -> Self: return self def write_config(self) -> Self: - """Writes the config to a file. + """Write the config to a file. Returns: Self @@ -74,15 +83,16 @@ def get_value(self, key: str) -> str: return '' if 'vapor' in self._config_data.sections() and key in self._config_data.options( - 'vapor' + 'vapor', ): return self._config_data.get('vapor', key) return '' def read_config(self) -> Self: - """Read the config from the file location. If file does not exist, a - blank config is loaded. + """Read the config from the file location. + + If file does not exist, a blank config is loaded. Returns: Self diff --git a/vapor/data_structures.py b/vapor/data_structures.py index 1f276e0..66765be 100644 --- a/vapor/data_structures.py +++ b/vapor/data_structures.py @@ -1,3 +1,5 @@ +"""Vapor's global data structures.""" + from enum import Enum from typing import Dict, List, NamedTuple @@ -18,6 +20,13 @@ 4. Uncheck the Always keep my total playtime private option """.strip() +HTTP_SUCCESS = 200 +HTTP_BAD_REQUEST = 400 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 +STEAM_USER_ID_LENGTH = 17 + + _ANTI_CHEAT_COLORS: Dict[str, str] = { 'Denied': 'red', 'Broken': 'dark_orange3', @@ -85,8 +94,11 @@ class Game(NamedTuple): class SteamUserData(NamedTuple): - """The data for a steam user. Includes a list of games and their respective - ProtonDB ratings, as well as the user's average ProtonDB rating.""" + """The data for a steam user. + + Includes a list of games and their respective ProtonDB ratings, + as well as the user's average ProtonDB rating. + """ game_ratings: List[Game] """The user's game ratings from ProtonDB.""" diff --git a/vapor/exceptions.py b/vapor/exceptions.py index c136c81..4ca8611 100644 --- a/vapor/exceptions.py +++ b/vapor/exceptions.py @@ -1,3 +1,6 @@ +"""Vapor's custom exceptions.""" + + class InvalidIDError(Exception): """If an invalid Steam ID is used, this error will be raised.""" diff --git a/vapor/main.py b/vapor/main.py index 3b2d4c1..bda7372 100644 --- a/vapor/main.py +++ b/vapor/main.py @@ -1,10 +1,13 @@ +"""Main code and UI.""" + from pathlib import Path -from typing import Optional +from typing import ClassVar, List, Optional from urllib.parse import urlparse from rich.text import Text from textual import on, work from textual.app import App, ComposeResult +from textual.binding import Binding, BindingType from textual.containers import Center, Container, Horizontal, VerticalScroll from textual.screen import ModalScreen, Screen from textual.validation import Regex @@ -36,13 +39,19 @@ class SettingsScreen(Screen): - BINDINGS = [('escape', 'app.pop_screen', 'Close Settings')] + """Settings editor screen for modifying the config file.""" + + BINDINGS: ClassVar[List[BindingType]] = [ + Binding('escape', 'app.pop_screen', 'Close Settings', show=True), + ] - def __init__(self, config): + def __init__(self, config: Config) -> None: + """Construct the Settings screen.""" self.config: Config = config super().__init__() def compose(self) -> ComposeResult: + """Compose the Settings screen with textual components.""" with Container(id='content-container'): yield Markdown('# Settings', classes='heading') @@ -57,18 +66,27 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: + """On mount, check that all the needed config values have been set in config. + + This is useful for migration of older versions to newer versions when new + configuration options have been added. + """ if not self.config.get_value('preserve-user-id'): self.config.set_value('preserve-user-id', 'false') self.config.write_config() @on(Switch.Changed) - def on_setting_changed(self, event: Switch.Changed): + def on_setting_changed(self, event: Switch.Changed) -> None: + """Whenever a setting has changed, update it in the config file.""" self.config.set_value(event.switch.id, str(event.value).lower()) # type: ignore self.config.write_config() class PrivateAccountScreen(ModalScreen): + """Error screen for private account errors.""" + def compose(self) -> ComposeResult: + """Compose the error screen with textual components.""" yield Center( Label(PRIVATE_ACCOUNT_HELP_MESSAGE, id='acct-info'), Button('Close', variant='error', id='close-acct-screen'), @@ -76,15 +94,24 @@ def compose(self) -> ComposeResult: ) def on_button_pressed(self) -> None: + """When the dismiss button is pressed, close the screen.""" self.dismiss() class SteamApp(App): + """Main application class.""" + CSS_PATH = 'main.tcss' TITLE = 'Steam Profile Proton Compatibility Checker' - BINDINGS = [('ctrl+s', "push_screen('settings')", 'Settings')] + BINDINGS: ClassVar[List[BindingType]] = [ + Binding('ctrl+s', "push_screen('settings')", 'Settings', show=True), + ] - def __init__(self, custom_config: Optional[Config] = None): + def __init__(self, custom_config: Optional[Config] = None) -> None: + """Construct the application. + + This reads and instantiates the config. + """ if custom_config is None: custom_config = Config() @@ -92,6 +119,7 @@ def __init__(self, custom_config: Optional[Config] = None): super().__init__() def compose(self) -> ComposeResult: + """Compose the application from textual components.""" self.show_account_help_dialog = False yield Header() yield Container( @@ -117,7 +145,7 @@ def compose(self) -> ComposeResult: Label( Text.assemble('User Average Rating: ', ('N/A', 'magenta')), id='user-rating', - ) + ), ), DataTable(zebra_stripes=True), id='body', @@ -125,6 +153,7 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: + """On mount, we initialize the table columns.""" # add nothing to table so that it shows up table = self.query_one(DataTable) table.add_columns('Title', 'Compatibility', 'Anti-Cheat Compatibility') @@ -138,6 +167,7 @@ def on_mount(self) -> None: @on(Button.Pressed, '#submit-button') @on(Input.Submitted) async def populate_table(self) -> None: + """Populate datatable with game information when submit button is pressed.""" try: # disable all Input widgets for item in self.query(Input): @@ -157,26 +187,26 @@ async def populate_table(self) -> None: # get user's API key and ID api_key: Input = self.query_one('#api-key') # type: ignore - id: Input = self.query_one('#user-id') # type: ignore + user_id: Input = self.query_one('#user-id') # type: ignore self.config.set_value('steam-api-key', api_key.value) # parse id input to add URL compatibility - parsed_url = urlparse(id.value) + parsed_url = urlparse(user_id.value) if parsed_url.netloc == 'steamcommunity.com' and ( '/profiles/' in parsed_url.path or '/id/' in parsed_url.path ): - id.value = Path(parsed_url.path).name - id.refresh() + user_id.value = Path(parsed_url.path).name + user_id.refresh() if self.config.get_value('preserve-user-id') == 'true': - self.config.set_value('user-id', id.value) + self.config.set_value('user-id', user_id.value) # fetch anti-cheat data cache = await get_anti_cheat_data() # Fetch user data - user_data = await get_steam_user_data(api_key.value, id.value) + user_data = await get_steam_user_data(api_key.value, user_id.value) table.clear() # Add games and ratings to the DataTable @@ -207,7 +237,7 @@ async def populate_table(self) -> None: user_data.user_average.capitalize(), RATING_DICT[user_data.user_average][1], ), - ) + ), ) except InvalidIDError: self.notify('Invalid Steam User ID', title='Error', severity='error')