diff --git a/pyproject.toml b/pyproject.toml index d8ae81a..978088f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,8 +83,8 @@ compile.cmd = "python -O -m PyInstaller pyinstaller.spec --clean" compile.env = {PYTHONHASHSEED = "1", SOURCE_DATE_EPOCH = "0"} # https://pyinstaller.org/en/stable/advanced-topics.html#creating-a-reproducible-build debug = "python -m pdb -m kleinanzeigen_bot" format = {shell = "autopep8 --recursive --in-place scripts src tests --verbose && python scripts/post_autopep8.py scripts src tests" } -lint = {shell = "ruff check && mypy && basedpyright" } -fix = {shell = "ruff check --fix" } +lint = {shell = "ruff check --preview && mypy && basedpyright" } +fix = {shell = "ruff check --preview --fix" } test = "python -m pytest --capture=tee-sys -v" utest = "python -m pytest --capture=tee-sys -v -m 'not itest'" itest = "python -m pytest --capture=tee-sys -v -m 'itest'" @@ -128,10 +128,10 @@ select = [ #"BLE", # flake8-blind-except "B", # flake8-bugbear "C4", # flake8-comprehensions - #"COM", # flake8-commas - #"CPY", # flake8-copyright + "COM", # flake8-commas + "CPY", # flake8-copyright "DTZ", # flake8-datetimez - #"EM", # TODO flake8-errmsg + #"EM", # flake8-errmsg #"ERA", # eradicate commented-out code "EXE", # flake8-executable "FA", # flake8-future-annotations @@ -175,37 +175,38 @@ select = [ ] ignore = [ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed - "ASYNC210", # TODO Async functions should not call blocking HTTP methods - "ASYNC230", # TODO Async functions should not open files with blocking methods like `open` - "D1", # Missing docstring in ... - "D200", # One-line docstring should fit on one line - "D202", # No blank lines allowed after function docstring (found 1) - "D203", # 1 blank line required before class docstring - "D204", # 1 blank line required after class docstring - "D205", # 1 blank line required between summary line and description - "D209", # Multi-line docstring closing quotes should be on a separate line" - "D212", # Multi-line docstring summary should start at the first line - "D213", # Multi-line docstring summary should start at the second line - "D400", # First line should end with a period - "D401", # First line of docstring should be in imperative mood - "D402", # First line should not be the function's signature - "D404", # First word of the docstring should not be "This" - "D413", # Missing blank line after last section ("Returns")" - "D415", # First line should end with a period, question mark, or exclamation point - "D417", # Missing argument description in the docstring for - #"E124", # Don't change indention of multi-line statements - #"E128", # Don't change indention of multi-line statements - #"E231", # Don't add whitespace after colon (:) on type declaration - #"E251", # Don't remove whitespace around parameter '=' sign. + "ASYNC210", # TODO Async functions should not call blocking HTTP methods + "ASYNC230", # TODO Async functions should not open files with blocking methods like `open` + "COM812", # Trailing comma missing + "D1", # Missing docstring in ... + "D200", # One-line docstring should fit on one line + "D202", # No blank lines allowed after function docstring (found 1) + "D203", # 1 blank line required before class docstring + "D204", # 1 blank line required after class docstring + "D205", # 1 blank line required between summary line and description + "D209", # Multi-line docstring closing quotes should be on a separate line" + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D400", # First line should end with a period + "D401", # First line of docstring should be in imperative mood + "D402", # First line should not be the function's signature + "D404", # First word of the docstring should not be "This" + "D413", # Missing blank line after last section ("Returns")" + "D415", # First line should end with a period, question mark, or exclamation point + "D417", # Missing argument description in the docstring for + #"E124", # Don't change indention of multi-line statements + #"E128", # Don't change indention of multi-line statements + "E231", # Don't add whitespace after colon (:) on type declaration + "E251", # Don't remove whitespace around parameter '=' sign. "E401", # Don't put imports on separate lines - "Q000", # TODO Single quotes found but double quotes preferred - "PERF203", # `try`-`except` within a loop incurs performance overhead - "RET504", # Unnecessary assignment to `...` before `return` statement - "PYI041", # Use `float` instead of `int | float` - "SIM102", # Use a single `if` statement instead of nested `if` statements - "SIM105", # Use `contextlib.suppress(TimeoutError)` instead of `try`-`except`-`pass` - "SIM114", # Combine `if` branches using logical `or` operator - "TC006", # Add quotes to type expression in `typing.cast()` + "PERF203", # `try`-`except` within a loop incurs performance overhead + "RET504", # Unnecessary assignment to `...` before `return` statement + "PLR6301", # Method `...` could be a function, class method, or static method + "PYI041", # Use `float` instead of `int | float` + "SIM102", # Use a single `if` statement instead of nested `if` statements + "SIM105", # Use `contextlib.suppress(TimeoutError)` instead of `try`-`except`-`pass` + "SIM114", # Combine `if` branches using logical `or` operator + "TC006", # Add quotes to type expression in `typing.cast()` ] [tool.ruff.lint.per-file-ignores] @@ -219,20 +220,25 @@ ignore = [ "INP", "SLF", "S101", # Use of `assert` detected - "PLR2004" # Magic value used in comparison + "PLR0904", # Too many public methods (12 > 10) + "PLR2004", # Magic value used in comparison ] +[tool.ruff.lint.flake8-copyright] +notice-rgx = "SPDX-FileCopyrightText: .*" +min-file-size = 256 + [tool.ruff.lint.pylint] # https://pylint.pycqa.org/en/latest/user_guide/configuration/all-options.html#design-checker # https://pylint.pycqa.org/en/latest/user_guide/checkers/features.html#design-checker-messages -#max-args = 6 # max. number of args for function / method (R0913) -#max-attributes = 15 # max. number of instance attrs for a class (R0902) -max-branches = 40 # max. number of branch for function / method body (R0912) -#max-locals = 30 # max. number of local vars for function / method body (R0914) -max-returns = 15 # max. number of return / yield for function / method body (R0911) -max-statements = 150 # max. number of statements in function / method body (R0915) -#max-public-methods = 30 # max. number of public methods for a class (R0904) -#max-positional-arguments = 6 # max. number of positional args for function / method (R0917) +max-args = 5 # max. number of args for function / method (R0913) +# max-attributes = 15 # max. number of instance attrs for a class (R0902) +max-branches = 40 # max. number of branch for function / method body (R0912) +max-locals = 30 # max. number of local vars for function / method body (R0914) +max-returns = 15 # max. number of return / yield for function / method body (R0911) +max-statements = 150 # max. number of statements in function / method body (R0915) +max-public-methods = 20 # max. number of public methods for a class (R0904) +# max-positional-arguments = 5 # max. number of positional args for function / method (R0917) ##################### diff --git a/scripts/post_autopep8.py b/scripts/post_autopep8.py index 2894d6e..d40c7d8 100644 --- a/scripts/post_autopep8.py +++ b/scripts/post_autopep8.py @@ -224,7 +224,7 @@ class PreferDoubleQuotesRule(FormatterRule): raw = new_lines[start_line] # apply shift so we match against current edited line idx = starting_col_offset + shift - if idx >= len(raw) or raw[idx] not in ("'", "r", "u", "b", "f", "R", "U", "B", "F"): + if idx >= len(raw) or raw[idx] not in {"'", "r", "u", "b", "f", "R", "U", "B", "F"}: continue # match literal at that column diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index f94db26..b116bdc 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -348,7 +348,7 @@ class KleinanzeigenBot(WebScrapingMixin): for ad_file, ad_file_relative in sorted(ad_files.items()): ad_cfg_orig = dicts.load_dict(ad_file, "ad") ad_cfg = copy.deepcopy(ad_cfg_orig) - dicts.apply_defaults(ad_cfg, self.config["ad_defaults"], ignore = lambda k, _: k == "description", override = lambda _, v: v == "") + dicts.apply_defaults(ad_cfg, self.config["ad_defaults"], ignore = lambda k, _: k == "description", override = lambda _, v: not v) dicts.apply_defaults(ad_cfg, ad_fields) if ignore_inactive and not ad_cfg["active"]: diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py index 69929dd..82e4daa 100644 --- a/src/kleinanzeigen_bot/extract.py +++ b/src/kleinanzeigen_bot/extract.py @@ -42,7 +42,7 @@ class AdExtractor(WebScrapingMixin): os.mkdir(relative_directory) LOG.info("Created ads directory at ./%s.", relative_directory) - new_base_dir = os.path.join(relative_directory, f'ad_{ad_id}') + new_base_dir = os.path.join(relative_directory, f"ad_{ad_id}") if os.path.exists(new_base_dir): LOG.info("Deleting current folder of ad %s...", ad_id) shutil.rmtree(new_base_dir) @@ -51,7 +51,7 @@ class AdExtractor(WebScrapingMixin): # call extraction function info = await self._extract_ad_page_info(new_base_dir, ad_id) - ad_file_path = new_base_dir + "/" + f'ad_{ad_id}.yaml' + ad_file_path = new_base_dir + "/" + f"ad_{ad_id}.yaml" dicts.save_dict(ad_file_path, info) async def _download_images_from_ad_page(self, directory:str, ad_id:int) -> list[str]: @@ -96,7 +96,7 @@ class AdExtractor(WebScrapingMixin): try: # click next button, wait, and re-establish reference await (await self.web_find(By.CLASS_NAME, "galleryimage--navigation--next")).click() - new_div = await self.web_find(By.CSS_SELECTOR, f'div.galleryimage-element:nth-child({img_nr + 1})') + new_div = await self.web_find(By.CSS_SELECTOR, f"div.galleryimage-element:nth-child({img_nr + 1})") img_element = await self.web_find(By.TAG_NAME, "img", parent = new_div) except TimeoutError: LOG.error("NEXT button in image gallery somehow missing, aborting image fetching.") diff --git a/src/kleinanzeigen_bot/utils/dicts.py b/src/kleinanzeigen_bot/utils/dicts.py index 5804ca2..204701a 100644 --- a/src/kleinanzeigen_bot/utils/dicts.py +++ b/src/kleinanzeigen_bot/utils/dicts.py @@ -61,7 +61,7 @@ def load_dict_if_exists(filepath:str, content_label:str = "") -> dict[str, Any] LOG.info("Loading %s[%s]...", content_label and content_label + _(" from ") or "", abs_filepath) __, file_ext = os.path.splitext(filepath) - if file_ext not in (".json", ".yaml", ".yml"): + if file_ext not in {".json", ".yaml", ".yml"}: raise ValueError(_('Unsupported file type. The filename "%s" must end with *.json, *.yaml, or *.yml') % filepath) if not os.path.exists(filepath): @@ -78,7 +78,7 @@ def load_dict_from_module(module:ModuleType, filename:str, content_label:str = " LOG.debug("Loading %s[%s.%s]...", content_label and content_label + " from " or "", module.__name__, filename) __, file_ext = os.path.splitext(filename) - if file_ext not in (".json", ".yaml", ".yml"): + if file_ext not in {".json", ".yaml", ".yml"}: raise ValueError(f'Unsupported file type. The filename "{filename}" must end with *.json, *.yaml, or *.yml') content = get_resource_as_string(module, filename) # pylint: disable=deprecated-method diff --git a/src/kleinanzeigen_bot/utils/misc.py b/src/kleinanzeigen_bot/utils/misc.py index 8bedea5..f377d27 100644 --- a/src/kleinanzeigen_bot/utils/misc.py +++ b/src/kleinanzeigen_bot/utils/misc.py @@ -13,7 +13,12 @@ from . import i18n T = TypeVar("T") -def ensure(condition:Any | bool | Callable[[], bool], error_message:str, timeout:float = 5, poll_requency:float = 0.5) -> None: +def ensure( + condition:Any | bool | Callable[[], bool], # noqa: FBT001 Boolean-typed positional argument in function definition + error_message:str, + timeout:float = 5, + poll_requency:float = 0.5 + ) -> None: """ :param timeout: timespan in seconds until when the condition must become `True`, default is 5 seconds :param poll_requency: sleep interval between calls in seconds, default is 0.5 seconds @@ -50,7 +55,7 @@ def is_frozen() -> bool: async def ainput(prompt:str) -> str: - return await asyncio.to_thread(input, f'{prompt} ') + return await asyncio.to_thread(input, f"{prompt} ") def parse_decimal(number:float | int | str) -> decimal.Decimal: diff --git a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py index 9825591..b4cc117 100644 --- a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py +++ b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py @@ -33,7 +33,7 @@ __all__ = [ LOG:Final[loggers.Logger] = loggers.get_logger(__name__) # see https://api.jquery.com/category/selectors/ -METACHAR_ESCAPER:Final[dict[int, str]] = str.maketrans({ch: f'\\{ch}' for ch in '!"#$%&\'()*+,./:;<=>?@[\\]^`{|}~'}) +METACHAR_ESCAPER:Final[dict[int, str]] = str.maketrans({ch: f"\\{ch}" for ch in '!"#$%&\'()*+,./:;<=>?@[\\]^`{|}~'}) class By(enum.Enum): @@ -535,13 +535,13 @@ class WebScrapingMixin: bottom_y_pos:int = await self.web_execute("document.body.scrollHeight") # get bottom position while current_y_pos < bottom_y_pos: # scroll in steps until bottom reached current_y_pos += scroll_length - await self.web_execute(f'window.scrollTo(0, {current_y_pos})') # scroll one step + await self.web_execute(f"window.scrollTo(0, {current_y_pos})") # scroll one step await asyncio.sleep(scroll_length / scroll_speed) if scroll_back_top: # scroll back to top in same style while current_y_pos > 0: current_y_pos -= scroll_length - await self.web_execute(f'window.scrollTo(0, {current_y_pos})') + await self.web_execute(f"window.scrollTo(0, {current_y_pos})") await asyncio.sleep(scroll_length / scroll_speed / 2) # double speed async def web_select(self, selector_type:By, selector_value:str, selected_value:Any, timeout:int | float = 5) -> Element: diff --git a/tests/unit/test_extract.py b/tests/unit/test_extract.py index 8825423..f4b88b2 100644 --- a/tests/unit/test_extract.py +++ b/tests/unit/test_extract.py @@ -380,7 +380,7 @@ class TestAdExtractorContent: ): try: info = await test_extractor._extract_ad_page_info("/some/dir", 12345) - assert info["description"] == "" + assert not info["description"] except TimeoutError: # This is also acceptable - depends on how we want to handle timeouts pass diff --git a/tests/unit/test_translations.py b/tests/unit/test_translations.py index 7774b47..4496672 100644 --- a/tests/unit/test_translations.py +++ b/tests/unit/test_translations.py @@ -257,7 +257,7 @@ def _find_translation(translations:TranslationDict, return bool(translations.get(module, {}).get(function, {}).get(message)) # Add kleinanzeigen_bot/ prefix if not present - module_path = f'kleinanzeigen_bot/{module}' if not module.startswith("kleinanzeigen_bot/") else module + module_path = f"kleinanzeigen_bot/{module}" if not module.startswith("kleinanzeigen_bot/") else module # Check if module exists in translations module_trans = translations.get(module_path, {}) @@ -301,7 +301,7 @@ def _message_exists_in_code(code_messages:dict[str, MessageDict], # Remove kleinanzeigen_bot/ prefix if present for code message lookup module_path = module[len("kleinanzeigen_bot/"):] if module.startswith("kleinanzeigen_bot/") else module - module_path = f'kleinanzeigen_bot/{module_path}' + module_path = f"kleinanzeigen_bot/{module_path}" # Check if module exists in code messages module_msgs = code_messages.get(module_path)