diff --git a/pydoll/browser/interfaces.py b/pydoll/browser/interfaces.py index d40ed765..4f029cef 100644 --- a/pydoll/browser/interfaces.py +++ b/pydoll/browser/interfaces.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod +from pydoll.browser.preference_types import BrowserPreferences from pydoll.constants import PageLoadState @@ -25,7 +26,7 @@ def add_argument(self, argument: str): @property @abstractmethod - def browser_preferences(self) -> dict: + def browser_preferences(self) -> BrowserPreferences: pass @property diff --git a/pydoll/browser/managers/temp_dir_manager.py b/pydoll/browser/managers/temp_dir_manager.py index 919a3397..6626b739 100644 --- a/pydoll/browser/managers/temp_dir_manager.py +++ b/pydoll/browser/managers/temp_dir_manager.py @@ -79,11 +79,11 @@ def handle_cleanup_error(self, func: Callable[[str], None], path: str, exc_info: Note: Handles Chromium-specific locked files like CrashpadMetrics. """ - matches = ['CrashpadMetrics-active.pma'] + matches = ['CrashpadMetrics-active.pma', 'Cookies', 'Network'] exc_type, exc_value, _ = exc_info if exc_type is PermissionError: - if Path(path).name in matches: + if Path(path).name in matches or 'Network' in str(Path(path)): try: self.retry_process_file(func, path) return diff --git a/pydoll/browser/options.py b/pydoll/browser/options.py index 9c7e5bef..3ee4a1f4 100644 --- a/pydoll/browser/options.py +++ b/pydoll/browser/options.py @@ -1,10 +1,14 @@ from contextlib import suppress +from typing import Any, Optional, cast from pydoll.browser.interfaces import Options +from pydoll.browser.preference_types import PREFERENCE_SCHEMA, BrowserPreferences from pydoll.constants import PageLoadState from pydoll.exceptions import ( ArgumentAlreadyExistsInOptions, ArgumentNotFoundInOptions, + InvalidPreferencePath, + InvalidPreferenceValue, WrongPrefsDict, ) @@ -24,12 +28,12 @@ def __init__(self): Sets up an empty list for command-line arguments and a string for the binary location of the browser. """ - self._arguments = [] - self._binary_location = '' - self._start_timeout = 10 - self._browser_preferences = {} - self._headless = False - self._page_load_state = PageLoadState.COMPLETE + self._arguments: list[str] = [] + self._binary_location: str = '' + self._start_timeout: int = 10 + self._browser_preferences: dict[str, Any] = {} + self._headless: bool = False + self._page_load_state: PageLoadState = PageLoadState.COMPLETE @property def arguments(self) -> list[str]: @@ -121,16 +125,18 @@ def remove_argument(self, argument: str): self._arguments.remove(argument) @property - def browser_preferences(self) -> dict: - return self._browser_preferences + def browser_preferences(self) -> BrowserPreferences: + return cast(BrowserPreferences, self._browser_preferences) @browser_preferences.setter - def browser_preferences(self, preferences: dict): + def browser_preferences(self, preferences: BrowserPreferences): if not isinstance(preferences, dict): raise ValueError('The experimental options value must be a dict.') if preferences.get('prefs'): - raise WrongPrefsDict + # deixar o WrongPrefsDict, mas com mensagem para ficar menos genĂ©rico + raise WrongPrefsDict("Top-level key 'prefs' is not allowed in browser preferences.") + # merge com preferĂȘncias existentes self._browser_preferences = {**self._browser_preferences, **preferences} def _set_pref_path(self, path: list, value): @@ -143,11 +149,65 @@ def _set_pref_path(self, path: list, value): path (e.g., ['plugins', 'always_open_pdf_externally']) value -- The value to set at the given path """ - d = self._browser_preferences + # validation will be handled in the updated implementation below + # (kept for backward-compatibility if callers rely on signature) + self._validate_pref_path(path) + self._validate_pref_value(path, value) + + d = cast(dict[str, Any], self._browser_preferences) for key in path[:-1]: d = d.setdefault(key, {}) d[path[-1]] = value + @staticmethod + def _validate_pref_path(path: list[str]) -> None: + """ + Validate that the provided path exists in the PREFERENCE_SCHEMA. + Raises InvalidPreferencePath when any segment is invalid. + """ + node = PREFERENCE_SCHEMA + for key in path: + if isinstance(node, dict) and key in node: + node = node[key] + else: + raise InvalidPreferencePath(f'Invalid preference path: {".".join(path)}') + + @staticmethod + def _validate_pref_value(path: list[str], value: Any) -> None: + """ + Validate the value type for the final segment in path against PREFERENCE_SCHEMA. + Supports recursive validation for nested dictionaries. + Raises InvalidPreferenceValue or InvalidPreferencePath on validation failure. + """ + node = PREFERENCE_SCHEMA + # Walk to the parent node (assumes path is valid from _validate_pref_path) + for key in path[:-1]: + node = node[key] + + final_key = path[-1] + expected = node[final_key] + + if isinstance(expected, dict): + # Expected is a subschema dict; value must be a dict and match the schema + if not isinstance(value, dict): + raise InvalidPreferenceValue( + f'Invalid value type for {".".join(path)}: ' + f'expected dict, got {type(value).__name__}' + ) + # Recursively validate each key-value in the value dict + for k, v in value.items(): + if k not in expected: + raise InvalidPreferencePath( + f'Invalid key "{k}" in preference path {".".join(path)}' + ) + ChromiumOptions._validate_pref_value(path + [k], v) + elif not isinstance(value, expected): + # Expected is a primitive type; check isinstance + raise InvalidPreferenceValue( + f'Invalid value type for {".".join(path)}: ' + f'expected {expected.__name__}, got {type(value).__name__}' + ) + def _get_pref_path(self, path: list): """ Safely gets a nested value from self._browser_preferences. @@ -159,6 +219,12 @@ def _get_pref_path(self, path: list): Returns: The value at the given path, or None if path doesn't exist """ + # validate path structure first; if invalid, raise a clear exception + try: + self._validate_pref_path(path) + except InvalidPreferencePath: + raise + nested_preferences = self._browser_preferences with suppress(KeyError, TypeError): for key in path: @@ -189,8 +255,9 @@ def set_accept_languages(self, languages: str): self._set_pref_path(['intl', 'accept_languages'], languages) @property - def prompt_for_download(self) -> bool: - return self._get_pref_path(['download', 'prompt_for_download']) + def prompt_for_download(self) -> Optional[bool]: + val = self._get_pref_path(['download', 'prompt_for_download']) + return val if isinstance(val, bool) else None @prompt_for_download.setter def prompt_for_download(self, enabled: bool): @@ -223,8 +290,9 @@ def block_popups(self, block: bool): ) @property - def password_manager_enabled(self) -> bool: - return self._get_pref_path(['profile', 'password_manager_enabled']) + def password_manager_enabled(self) -> Optional[bool]: + val = self._get_pref_path(['profile', 'password_manager_enabled']) + return val if isinstance(val, bool) else None @password_manager_enabled.setter def password_manager_enabled(self, enabled: bool): @@ -237,7 +305,7 @@ def password_manager_enabled(self, enabled: bool): enabled: If True, the password manager is active. """ self._set_pref_path(['profile', 'password_manager_enabled'], enabled) - self._set_pref_path(['credentials_enable_service'], enabled) + self._browser_preferences['credentials_enable_service'] = enabled @property def block_notifications(self) -> bool: @@ -291,8 +359,9 @@ def allow_automatic_downloads(self, allow: bool): ) @property - def open_pdf_externally(self) -> bool: - return self._get_pref_path(['plugins', 'always_open_pdf_externally']) + def open_pdf_externally(self) -> Optional[bool]: + val = self._get_pref_path(['plugins', 'always_open_pdf_externally']) + return val if isinstance(val, bool) else None @open_pdf_externally.setter def open_pdf_externally(self, enabled: bool): @@ -315,9 +384,8 @@ def headless(self, headless: bool): self._headless = headless has_argument = '--headless' in self.arguments methods_map = {True: self.add_argument, False: self.remove_argument} - if headless == has_argument: - return - methods_map[headless]('--headless') + if headless != has_argument: + methods_map[headless]('--headless') @property def page_load_state(self) -> PageLoadState: diff --git a/pydoll/browser/preference_types.py b/pydoll/browser/preference_types.py new file mode 100644 index 00000000..d91fde29 --- /dev/null +++ b/pydoll/browser/preference_types.py @@ -0,0 +1,56 @@ +from typing import TypedDict + +from typing_extensions import NotRequired + + +class DownloadPreferences(TypedDict): + default_directory: str + prompt_for_download: NotRequired[bool] + directory_upgrade: NotRequired[bool] + + +class ContentSettingValues(TypedDict, total=False): + popups: int + notifications: int + automatic_downloads: int + + +class ProfilePreferences(TypedDict): + password_manager_enabled: bool + default_content_setting_values: ContentSettingValues + + +class BrowserPreferences(TypedDict, total=False): + download: DownloadPreferences + profile: ProfilePreferences + intl: NotRequired[dict[str, str]] + plugins: NotRequired[dict[str, bool]] + credentials_enable_service: bool + + +# Runtime schema used for validating preference paths and value types. +# Keys map to either a python type (str/bool/int/dict) or to a nested dict +# describing child keys and their expected types. +PREFERENCE_SCHEMA: dict = { + 'download': { + 'default_directory': str, + 'prompt_for_download': bool, + 'directory_upgrade': bool, + }, + 'profile': { + 'password_manager_enabled': bool, + # default_content_setting_values is a mapping of content name -> int + 'default_content_setting_values': { + 'popups': int, + 'notifications': int, + 'automatic_downloads': int, + }, + }, + 'intl': { + 'accept_languages': str, + }, + 'plugins': { + 'always_open_pdf_externally': bool, + }, + 'credentials_enable_service': bool, +} diff --git a/pydoll/exceptions.py b/pydoll/exceptions.py index cab7fd5f..8afa04ff 100644 --- a/pydoll/exceptions.py +++ b/pydoll/exceptions.py @@ -319,6 +319,18 @@ class WrongPrefsDict(PydollException): message = 'The dict can not contain "prefs" key, provide only the prefs options' +class InvalidPreferencePath(PydollException): + """Raised when a provided preference path is invalid (segment doesn't exist).""" + + message = 'Invalid preference path' + + +class InvalidPreferenceValue(PydollException): + """Invalid value for a preference (incompatible type)""" + + message = 'Invalid preference value' + + class ElementPreconditionError(ElementException): """Raised when invalid or missing preconditions are provided for element operations.""" diff --git a/pyproject.toml b/pyproject.toml index fe8eb344..b969410e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,8 +58,8 @@ pythonpath = "." addopts = '-p no:warnings' [tool.taskipy.tasks] -lint = 'ruff check .; ruff check . --diff' -format = 'ruff check . --fix; ruff format .' +lint = 'ruff check . && ruff check . --diff' +format = 'ruff check . --fix && ruff format .' test = 'pytest -s -x --cov=pydoll -vv' post_test = 'coverage html' diff --git a/tests/test_browser/test_browser_preferences.py b/tests/test_browser/test_browser_preferences.py new file mode 100644 index 00000000..e02b3c73 --- /dev/null +++ b/tests/test_browser/test_browser_preferences.py @@ -0,0 +1,177 @@ +import pytest +from typing import Any, cast + +from pydoll.browser.options import ChromiumOptions +from pydoll.browser.preference_types import BrowserPreferences +from pydoll.exceptions import InvalidPreferencePath, InvalidPreferenceValue, WrongPrefsDict + + +def test_validate_pref_path_valid(): + """Test that valid preference paths are accepted.""" + options = ChromiumOptions() + # Should not raise + options._validate_pref_path(['download', 'default_directory']) + options._validate_pref_path(['profile', 'password_manager_enabled']) + options._validate_pref_path(['plugins', 'always_open_pdf_externally']) + + +def test_validate_pref_path_invalid(): + """Test that invalid preference paths raise InvalidPreferencePath.""" + options = ChromiumOptions() + with pytest.raises(InvalidPreferencePath): + options._validate_pref_path(['invalid', 'path']) + with pytest.raises(InvalidPreferencePath): + options._validate_pref_path(['download', 'invalid_key']) + + +def test_validate_pref_value_valid(): + """Test that valid preference values are accepted.""" + options = ChromiumOptions() + # Should not raise + options._validate_pref_value(['download', 'default_directory'], '/path/to/dir') + options._validate_pref_value(['profile', 'password_manager_enabled'], True) + options._validate_pref_value(['profile', 'default_content_setting_values', 'popups'], 1) + + +def test_validate_pref_value_invalid(): + """Test that invalid preference values raise InvalidPreferenceValue.""" + options = ChromiumOptions() + with pytest.raises(InvalidPreferenceValue): + options._validate_pref_value(['download', 'default_directory'], True) # should be str + with pytest.raises(InvalidPreferenceValue): + options._validate_pref_value(['profile', 'password_manager_enabled'], 'true') # should be bool + + +def test_browser_preferences_setter_valid(): + """Test setting valid browser preferences.""" + options = ChromiumOptions() + prefs: BrowserPreferences = { + 'download': { + 'default_directory': '/downloads', + 'prompt_for_download': True + }, + 'profile': { + 'password_manager_enabled': False, + 'default_content_setting_values': { + 'popups': 0, + 'notifications': 2 + } + } + } + options.browser_preferences = prefs + assert options.browser_preferences == prefs + + +def test_browser_preferences_setter_invalid_type(): + """Test setting browser preferences with invalid type.""" + options = ChromiumOptions() + with pytest.raises(ValueError): + # type: ignore[arg-type] + options.browser_preferences = ['not', 'a', 'dict'] + + +def test_browser_preferences_setter_invalid_prefs(): + """Test setting browser preferences with invalid prefs key.""" + options = ChromiumOptions() + with pytest.raises(WrongPrefsDict): + invalid_prefs = cast(BrowserPreferences, {'prefs': {'some': 'value'}}) + options.browser_preferences = invalid_prefs + + +def test_browser_preferences_merge(): + """Test that browser preferences are properly merged.""" + options = ChromiumOptions() + initial_prefs: BrowserPreferences = { + 'download': { + 'default_directory': '/downloads', + 'prompt_for_download': True + }, + 'profile': { + 'password_manager_enabled': True, + 'default_content_setting_values': { + 'popups': 0 + } + } + } + additional_prefs: BrowserPreferences = { + 'profile': { + 'password_manager_enabled': False, + 'default_content_setting_values': { + 'notifications': 2 + } + } + } + + options.browser_preferences = initial_prefs + options.browser_preferences = additional_prefs + + expected: BrowserPreferences = { + 'download': { + 'default_directory': '/downloads', + 'prompt_for_download': True + }, + 'profile': { + 'password_manager_enabled': False, + 'default_content_setting_values': { + 'notifications': 2 + } + } + } + assert options.browser_preferences == expected + + +def test_get_pref_path_existing(): + """Test getting existing preference paths.""" + options = ChromiumOptions() + prefs: BrowserPreferences = { + 'download': { + 'default_directory': '/downloads', + }, + 'profile': { + 'password_manager_enabled': True, + 'default_content_setting_values': {} + } + } + options.browser_preferences = prefs + assert options._get_pref_path(['download', 'default_directory']) == '/downloads' + + +def test_get_pref_path_nonexistent(): + """Test getting nonexistent preference paths returns None.""" + options = ChromiumOptions() + with pytest.raises(InvalidPreferencePath): + options._get_pref_path(['download', 'nonexistent']) + + +def test_set_pref_path_creates_structure(): + """Test that setting a preference path creates the necessary structure.""" + options = ChromiumOptions() + options.browser_preferences = cast(BrowserPreferences, { + 'profile': { + 'password_manager_enabled': True, + 'default_content_setting_values': {} + } + }) + options._set_pref_path(['profile', 'default_content_setting_values', 'popups'], 0) + assert cast(Any, options.browser_preferences)['profile']['default_content_setting_values']['popups'] == 0 + + +def test_validate_pref_value_dict_invalid_type(): + """Test that passing non-dict value for dict expected raises InvalidPreferenceValue.""" + options = ChromiumOptions() + with pytest.raises(InvalidPreferenceValue): + options._validate_pref_value(['profile', 'default_content_setting_values'], 'not_a_dict') + + +def test_validate_pref_value_dict_invalid_key(): + """Test that passing dict with invalid key raises InvalidPreferencePath.""" + options = ChromiumOptions() + with pytest.raises(InvalidPreferencePath): + options._validate_pref_value(['profile', 'default_content_setting_values'], {'invalid_key': 1}) + + +def test_validate_pref_value_dict_valid(): + """Test that passing valid dict for dict expected works.""" + options = ChromiumOptions() + # Should not raise + options._validate_pref_value(['profile', 'default_content_setting_values'], {'popups': 1, 'notifications': 2}) \ No newline at end of file