diff --git a/README.md b/README.md index d6770f7..0742c2a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,18 @@ Alternatively if you don't know/care which browser has the cookies you want then 'richardpenman / home — Bitbucket' ``` +To load cookies from a specific subset of browsers: +```python +import browser_cookie3 +cj = browser_cookie3.load(domain_name='example.com', browsers=['firefox', 'chrome']) +``` + +To get a list of all supported browser names: +```python +>>> list(browser_cookie3.BROWSERS) +['chrome', 'chromium', 'opera', 'opera_gx', 'brave', 'edge', 'vivaldi', 'firefox', 'librewolf', 'safari', 'lynx', 'w3m', 'arc'] +``` + Alternatively if you are only interested in cookies from a specific domain, you can specify a domain filter. ```python #!python diff --git a/browser_cookie3/__init__.py b/browser_cookie3/__init__.py index 855bf7c..f36aebc 100644 --- a/browser_cookie3/__init__.py +++ b/browser_cookie3/__init__.py @@ -15,7 +15,7 @@ import tempfile from io import BytesIO from pathlib import Path -from typing import Dict, List, Union +from typing import Callable, Dict, Iterable, List, Optional, Union if sys.platform.startswith('linux') or 'bsd' in sys.platform.lower(): @@ -1404,12 +1404,45 @@ def w3m(cookie_file=None, domain_name=""): all_browsers = [chrome, chromium, opera, opera_gx, brave, edge, vivaldi, firefox, librewolf, safari, lynx, w3m, arc] -def load(domain_name=""): - """Try to load cookies from all supported browsers and return combined cookiejar - Optionally pass in a domain name to only load cookies from the specified domain +BROWSERS: Dict[str, Callable] = {fn.__name__: fn for fn in all_browsers} +"""Mapping of browser name to its cookie-loader function. + +Useful for input validation and dynamic dispatch:: + + # Validate a user-supplied name + if name not in browser_cookie3.BROWSERS: + raise ValueError(f"Unknown browser: {name!r}") + + # Dispatch by name + cj = browser_cookie3.BROWSERS[name](domain_name="example.com") +""" + + +def load(domain_name: str = "", browsers: Optional[Iterable[Union[str, Callable]]] = None): + """Try to load cookies from supported browsers and return combined cookiejar. + + Parameters + ---------- + domain_name: + Only load cookies from this domain. Passed through to each browser + loader. Defaults to ``""`` (all domains). + browsers: + Browsers to try. Each entry may be a name string (looked up in + :data:`BROWSERS`) or a callable browser loader directly, e.g.:: + + load(browsers=["firefox", "chrome"]) + load(browsers=["firefox", browser_cookie3.chrome]) + + Defaults to ``None``, which tries all browsers in :data:`all_browsers` + (existing behaviour). """ + if browsers is None: + fns = all_browsers + else: + fns = [BROWSERS[b] if isinstance(b, str) else b for b in browsers] + cj = http.cookiejar.CookieJar() - for cookie_fn in all_browsers: + for cookie_fn in fns: try: for cookie in cookie_fn(domain_name=domain_name): cj.set_cookie(cookie) @@ -1418,7 +1451,7 @@ def load(domain_name=""): return cj -__all__ = ['BrowserCookieError', 'load', 'all_browsers'] + all_browsers +__all__ = ['BrowserCookieError', 'load', 'all_browsers', 'BROWSERS'] + all_browsers if __name__ == '__main__': diff --git a/tests/test_browsers_api.py b/tests/test_browsers_api.py new file mode 100644 index 0000000..7416703 --- /dev/null +++ b/tests/test_browsers_api.py @@ -0,0 +1,176 @@ +"""Tests for the BROWSERS dict and the browsers= parameter on load(). + +These tests do not require any browser to be installed or any fixture cookie +files. They mock the individual browser loader functions so the only thing +under test is the new API surface introduced by this PR. +""" + +import http.cookiejar +import unittest +from unittest.mock import MagicMock, call, patch + +import browser_cookie3 + + +def _make_cookie(name, value, domain="example.com"): + """Create a minimal http.cookiejar.Cookie for testing.""" + return http.cookiejar.Cookie( + version=0, name=name, value=value, + port=None, port_specified=False, + domain=domain, domain_specified=True, domain_initial_dot=False, + path="/", path_specified=True, + secure=False, expires=None, discard=True, + comment=None, comment_url=None, rest={}, + ) + + +class TestBROWSERS(unittest.TestCase): + + def test_is_dict(self): + self.assertIsInstance(browser_cookie3.BROWSERS, dict) + + def test_keys_are_strings(self): + for key in browser_cookie3.BROWSERS: + self.assertIsInstance(key, str, f"Key {key!r} is not a str") + + def test_values_are_callable(self): + for name, fn in browser_cookie3.BROWSERS.items(): + self.assertTrue(callable(fn), f"BROWSERS[{name!r}] is not callable") + + def test_matches_all_browsers(self): + """BROWSERS must contain exactly the same functions as all_browsers, in order.""" + self.assertEqual( + list(browser_cookie3.BROWSERS.values()), + browser_cookie3.all_browsers, + ) + + def test_keys_match_function_names(self): + for name, fn in browser_cookie3.BROWSERS.items(): + self.assertEqual(name, fn.__name__) + + def test_known_browsers_present(self): + for name in ("chrome", "firefox", "safari", "edge", "brave", + "chromium", "opera", "vivaldi", "librewolf"): + self.assertIn(name, browser_cookie3.BROWSERS, f"{name!r} missing from BROWSERS") + + def test_in_dunder_all(self): + self.assertIn("BROWSERS", browser_cookie3.__all__) + + def test_no_non_browser_callables(self): + """Helpers like open_dbus_connection and unpad must not appear.""" + for name in browser_cookie3.BROWSERS: + self.assertNotIn(name, ("load", "create_cookie", "open_dbus_connection", "unpad"), + f"Non-browser callable {name!r} leaked into BROWSERS") + + +class TestLoadBrowsersParam(unittest.TestCase): + + def _mock_loader(self, cookies): + """Return a mock browser loader that yields the given cookies.""" + mock = MagicMock() + mock.return_value = iter(cookies) + return mock + + def test_default_uses_all_browsers(self): + """load() with no browsers= arg calls every loader in all_browsers.""" + mocks = [self._mock_loader([]) for _ in browser_cookie3.all_browsers] + with patch.object(browser_cookie3, 'all_browsers', mocks): + browser_cookie3.load() + for mock in mocks: + mock.assert_called_once() + + def test_subset_by_name(self): + """load(browsers=['firefox', 'chrome']) calls only those two loaders.""" + ff_cookie = _make_cookie("ff_session", "abc") + ch_cookie = _make_cookie("ch_session", "xyz") + mock_ff = self._mock_loader([ff_cookie]) + mock_ch = self._mock_loader([ch_cookie]) + + with patch.dict(browser_cookie3.BROWSERS, {"firefox": mock_ff, "chrome": mock_ch}): + cj = browser_cookie3.load(browsers=["firefox", "chrome"]) + + mock_ff.assert_called_once() + mock_ch.assert_called_once() + cookies = list(cj) + self.assertEqual(len(cookies), 2) + names = {c.name for c in cookies} + self.assertIn("ff_session", names) + self.assertIn("ch_session", names) + + def test_subset_by_callable(self): + """load(browsers=[browser_cookie3.firefox]) accepts callables directly.""" + cookie = _make_cookie("tok", "secret") + mock_ff = self._mock_loader([cookie]) + + with patch.dict(browser_cookie3.BROWSERS, {"firefox": mock_ff}): + # Pass the mock as a callable, not by name + cj = browser_cookie3.load(browsers=[mock_ff]) + + mock_ff.assert_called_once() + self.assertEqual(len(list(cj)), 1) + + def test_mixed_names_and_callables(self): + """load() accepts a mix of name strings and callables.""" + c1 = _make_cookie("c1", "v1") + c2 = _make_cookie("c2", "v2") + mock_ff = self._mock_loader([c1]) + mock_ch = self._mock_loader([c2]) + + with patch.dict(browser_cookie3.BROWSERS, {"firefox": mock_ff, "chrome": mock_ch}): + cj = browser_cookie3.load(browsers=["firefox", mock_ch]) + + mock_ff.assert_called_once() + mock_ch.assert_called_once() + self.assertEqual(len(list(cj)), 2) + + def test_empty_browsers_returns_empty_jar(self): + """load(browsers=[]) returns an empty CookieJar, not all browsers.""" + # Ensure no loader in all_browsers gets called + mocks = [self._mock_loader([]) for _ in browser_cookie3.all_browsers] + with patch.object(browser_cookie3, 'all_browsers', mocks): + cj = browser_cookie3.load(browsers=[]) + for mock in mocks: + mock.assert_not_called() + self.assertEqual(len(list(cj)), 0) + + def test_domain_name_forwarded(self): + """domain_name= is forwarded to each loader.""" + mock_ff = self._mock_loader([]) + with patch.dict(browser_cookie3.BROWSERS, {"firefox": mock_ff}): + browser_cookie3.load(domain_name="example.com", browsers=["firefox"]) + mock_ff.assert_called_once_with(domain_name="example.com") + + def test_browser_cookie_error_is_swallowed(self): + """A BrowserCookieError from one loader does not abort the others.""" + cookie = _make_cookie("surviving", "yes") + failing_loader = MagicMock(side_effect=browser_cookie3.BrowserCookieError("not found")) + ok_loader = self._mock_loader([cookie]) + + with patch.dict(browser_cookie3.BROWSERS, {"chrome": failing_loader, "firefox": ok_loader}): + cj = browser_cookie3.load(browsers=["chrome", "firefox"]) + + cookies = list(cj) + self.assertEqual(len(cookies), 1) + self.assertEqual(cookies[0].name, "surviving") + + def test_unknown_browser_name_raises_key_error(self): + """Passing an unknown string raises KeyError immediately.""" + with self.assertRaises(KeyError): + browser_cookie3.load(browsers=["netscape"]) + + def test_returns_cookiejar(self): + """load() always returns an http.cookiejar.CookieJar.""" + cj = browser_cookie3.load(browsers=[]) + self.assertIsInstance(cj, http.cookiejar.CookieJar) + + def test_none_is_identical_to_omitting_param(self): + """Explicitly passing browsers=None behaves the same as not passing it.""" + mocks = [self._mock_loader([]) for _ in browser_cookie3.all_browsers] + with patch.object(browser_cookie3, 'all_browsers', mocks): + browser_cookie3.load(browsers=None) + for mock in mocks: + mock.assert_called_once() + + +if __name__ == "__main__": + unittest.main()