Skip to content
3 changes: 2 additions & 1 deletion pydoll/browser/interfaces.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod

from pydoll.browser.preference_types import BrowserPreferences
from pydoll.constants import PageLoadState


Expand All @@ -25,7 +26,7 @@ def add_argument(self, argument: str):

@property
@abstractmethod
def browser_preferences(self) -> dict:
def browser_preferences(self) -> BrowserPreferences:
pass

@property
Expand Down
4 changes: 2 additions & 2 deletions pydoll/browser/managers/temp_dir_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 89 additions & 21 deletions pydoll/browser/options.py
Original file line number Diff line number Diff line change
@@ -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,
)

Expand All @@ -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]:
Expand Down Expand Up @@ -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):
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions pydoll/browser/preference_types.py
Original file line number Diff line number Diff line change
@@ -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,
}
12 changes: 12 additions & 0 deletions pydoll/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Loading
Loading