diff --git a/proxyproviders/__init__.py b/proxyproviders/__init__.py index 914785a..2f9bb82 100644 --- a/proxyproviders/__init__.py +++ b/proxyproviders/__init__.py @@ -1,13 +1,15 @@ from . import algorithms -from .models.proxy import Proxy +from .models.proxy import Proxy, ProxyFormat from .providers.brightdata import BrightData from .providers.webshare import Webshare from .proxy_provider import ProxyConfig, ProxyProvider +from .version import __version__ __all__ = [ "ProxyProvider", "ProxyConfig", "Proxy", + "ProxyFormat", "Webshare", "BrightData", "algorithms", diff --git a/proxyproviders/models/proxy.py b/proxyproviders/models/proxy.py index 040d638..e98119a 100644 --- a/proxyproviders/models/proxy.py +++ b/proxyproviders/models/proxy.py @@ -19,6 +19,9 @@ class ProxyFormat(Enum): AIOHTTP = "aiohttp" """AIOHTTP format, for use in aiohttp library HTTP calls""" + PLAYWRIGHT = "playwright" + """Playwright format, for use in Playwright browser automation""" + URL = "url" """URL format, for use in URL strings""" @@ -90,29 +93,57 @@ def format( >>> proxy.format(ProxyFormat.HTTPX) {'http://': 'http://user:pass@192.168.1.1:8080', 'https://': 'http://user:pass@192.168.1.1:8080'} """ + # Convert to ProxyFormat enum, handling both string and enum inputs if isinstance(format_type, str): - format_type = ProxyFormat(format_type) - - if format_type == ProxyFormat.URL: - protocol = kwargs.get("protocol", "http") - return self.to_url(protocol) - - elif format_type == ProxyFormat.REQUESTS: - protocols = kwargs.get("protocols", self.protocols or ["http", "https"]) - proxy_url = self.to_url("http") - return {protocol: proxy_url for protocol in protocols} - - elif format_type == ProxyFormat.CURL: - return ["-x", self.to_url("http")] - - elif format_type == ProxyFormat.HTTPX: - # httpx uses 'http://' and 'https://' as keys - proxy_url = self.to_url("http") - return {"http://": proxy_url, "https://": proxy_url} - - elif format_type == ProxyFormat.AIOHTTP: - # aiohttp takes a single URL string - return self.to_url("http") - - else: - raise ValueError(f"Unsupported format: {format_type}") + try: + format_type = ProxyFormat(format_type) + except ValueError: + raise ValueError( + f"Invalid format type: '{format_type}'. Valid options are: {[f.value for f in ProxyFormat]}" + ) + elif not isinstance(format_type, ProxyFormat): + raise ValueError( # pyright: ignore[reportUnreachable] this is actually reachable + f"Invalid format type: {type(format_type).__name__}. Expected ProxyFormat enum or string." + ) + + format_handlers = { + ProxyFormat.URL: lambda: self.to_url(kwargs.get("protocol", "http")), + ProxyFormat.REQUESTS: lambda: self._format_requests(kwargs), + ProxyFormat.CURL: lambda: ["-x", self.to_url("http")], + ProxyFormat.HTTPX: lambda: self._format_httpx(), + ProxyFormat.AIOHTTP: lambda: self.to_url("http"), + ProxyFormat.PLAYWRIGHT: lambda: self._format_playwright(**kwargs), + } + + handler = format_handlers[format_type] + return handler() + + def _format_requests(self, kwargs): + """Format proxy for requests library.""" + protocols = kwargs.get("protocols", self.protocols or ["http", "https"]) + proxy_url = self.to_url("http") + return {protocol: proxy_url for protocol in protocols} + + def _format_httpx(self): + """Format proxy for httpx library.""" + proxy_url = self.to_url("http") + return {"http://": proxy_url, "https://": proxy_url} + + def _format_playwright(self, **kwargs): + """Format proxy for Playwright.""" + # Playwright expects server with protocol (e.g., 'http://ip:port', 'socks5://ip:port') + # Allow protocol selection via kwargs, default to http + protocol = kwargs.get("protocol", "http") + + # Validate protocol is supported by the proxy + if self.protocols and protocol not in self.protocols: + # If proxy has specific protocols, use the first available one + protocol = self.protocols[0] + + playwright_proxy = {"server": f"{protocol}://{self.proxy_address}:{self.port}"} + + if self.username and self.password: + playwright_proxy["username"] = self.username + playwright_proxy["password"] = self.password + + return playwright_proxy diff --git a/proxyproviders/version.py b/proxyproviders/version.py new file mode 100644 index 0000000..df44003 --- /dev/null +++ b/proxyproviders/version.py @@ -0,0 +1,3 @@ +"""Version information for proxyproviders package.""" + +__version__ = "0.2.1" diff --git a/pyproject.toml b/pyproject.toml index 9ef54ca..824ecd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [project] name = "proxyproviders" -version = "0.2.0" +version = "0.2.1" description = "A unified interface for different proxy providers" readme = "README.md" authors = [{ name = "David Teather", email = "contact.davidteather@gmail.com" }] diff --git a/setup.cfg b/setup.cfg index 7e7dee2..c8ba5ae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,21 @@ +[bumpversion] +current_version = 0.2.1 + +[bumpversion:file:setup.cfg] +search = version = {current_version} +replace = version = {new_version} + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:proxyproviders/version.py] +search = __version__ = "{current_version}" +replace = __version__ = "{new_version}" + [metadata] name = proxyproviders -version = 0.2.0 +version = attr: proxyproviders.version.__version__ description = A unified interface for different proxy providers long_description = file: README.md long_description_content_type = text/markdown @@ -8,10 +23,10 @@ author = David Teather author_email = contact.davidteather@gmail.com license = MIT url = https://github.com/davidteather/proxyproviders -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: MIT License - Operating System :: OS Independent +classifiers = + Programming Language :: Python :: 3 + License :: OSI Approved :: MIT License + Operating System :: OS Independent [options] packages = find: diff --git a/tests/integration/test_algorithms_integration.py b/tests/integration/test_algorithms_integration.py index f10bafd..96761a6 100644 --- a/tests/integration/test_algorithms_integration.py +++ b/tests/integration/test_algorithms_integration.py @@ -96,6 +96,119 @@ def test_webshare_proxy_conversion_methods(): assert "https" in requests_dict print(f"Requests dict: {requests_dict}") + # Test Playwright format (default protocol) + playwright_dict = proxy.format(ProxyFormat.PLAYWRIGHT) + assert isinstance(playwright_dict, dict) + assert "server" in playwright_dict + assert playwright_dict["server"].startswith("http://") + assert f"{proxy.proxy_address}:{proxy.port}" in playwright_dict["server"] + assert "username" in playwright_dict + assert "password" in playwright_dict + assert playwright_dict["username"] == proxy.username + assert playwright_dict["password"] == proxy.password + + # Test Playwright format with different protocols + for protocol in ["http", "https"]: + playwright_protocol = proxy.format(ProxyFormat.PLAYWRIGHT, protocol=protocol) + assert playwright_protocol["server"].startswith(f"{protocol}://") + + +@skip_integration +def test_webshare_playwright_format_comprehensive(): + """Comprehensive test of Playwright format with real Webshare API.""" + api_key = os.getenv("WEBSHARE_API_KEY") + provider = Webshare(api_key=api_key) + + proxy = provider.get_proxy() + + # Test 1: Default Playwright format + default_format = proxy.format(ProxyFormat.PLAYWRIGHT) + assert isinstance(default_format, dict) + assert "server" in default_format + assert "username" in default_format + assert "password" in default_format + assert default_format["server"].startswith("http://") + + # Test 2: Protocol variations + protocols_to_test = ["http", "https"] + + for protocol in protocols_to_test: + result = proxy.format(ProxyFormat.PLAYWRIGHT, protocol=protocol) + assert result["server"].startswith(f"{protocol}://") + assert result["username"] == proxy.username + assert result["password"] == proxy.password + + # Test 3: String format + string_format = proxy.format("playwright") + assert string_format == default_format + + # Test 4: Format consistency + enum_format = proxy.format(ProxyFormat.PLAYWRIGHT) + string_format = proxy.format("playwright") + assert enum_format == string_format + + # Test 5: Playwright documentation compliance + playwright_config = proxy.format(ProxyFormat.PLAYWRIGHT) + + # Verify structure matches Playwright docs + required_fields = ["server"] + optional_fields = ["username", "password"] + + for field in required_fields: + assert field in playwright_config, f"Missing required field: {field}" + + for field in optional_fields: + if field in playwright_config: + assert True # Field is present + else: + assert False, f"Missing optional field: {field}" + + # Verify server format + server = playwright_config["server"] + assert "://" in server, "Server should include protocol" + assert ( + f"{proxy.proxy_address}:{proxy.port}" in server + ), "Server should include address and port" + + +@skip_integration +def test_webshare_playwright_e2e_simulation(): + """End-to-end simulation of Playwright usage with real Webshare proxy.""" + api_key = os.getenv("WEBSHARE_API_KEY") + provider = Webshare(api_key=api_key) + + proxy = provider.get_proxy() + + # Convert to Playwright format + playwright_config = proxy.format(ProxyFormat.PLAYWRIGHT) + + # Verify the format is exactly what Playwright expects + # Check server format + server = playwright_config["server"] + assert "://" in server, "Server must include protocol" + assert ( + f"{proxy.proxy_address}:{proxy.port}" in server + ), "Server must include address and port" + + # Check authentication + assert "username" in playwright_config, "Username field is required" + assert "password" in playwright_config, "Password field is required" + + # Check required fields + assert "server" in playwright_config, "Server field is required" + + # Check optional fields + optional_fields = ["username", "password"] + for field in optional_fields: + assert field in playwright_config, f"Missing optional field: {field}" + + # Test different protocols + protocols = ["http", "https"] + for protocol in protocols: + config = proxy.format(ProxyFormat.PLAYWRIGHT, protocol=protocol) + server = config["server"] + assert server.startswith(f"{protocol}://") + @skip_integration def test_webshare_e2e_with_requests(): diff --git a/tests/integration/test_brightdata_algorithms_integration.py b/tests/integration/test_brightdata_algorithms_integration.py index ba02b67..17b81e2 100644 --- a/tests/integration/test_brightdata_algorithms_integration.py +++ b/tests/integration/test_brightdata_algorithms_integration.py @@ -94,6 +94,86 @@ def test_brightdata_proxy_conversion_methods(): assert "https" in requests_dict print(f"BrightData Requests dict: {requests_dict}") + # Test Playwright format (default protocol) + playwright_dict = proxy.format(ProxyFormat.PLAYWRIGHT) + assert isinstance(playwright_dict, dict) + assert "server" in playwright_dict + assert playwright_dict["server"].startswith("http://") + assert f"{proxy.proxy_address}:{proxy.port}" in playwright_dict["server"] + assert "username" in playwright_dict + assert "password" in playwright_dict + assert playwright_dict["username"] == proxy.username + assert playwright_dict["password"] == proxy.password + + # Test Playwright format with different protocols + for protocol in ["http", "https"]: + playwright_protocol = proxy.format(ProxyFormat.PLAYWRIGHT, protocol=protocol) + assert playwright_protocol["server"].startswith(f"{protocol}://") + + +@skip_integration +def test_brightdata_playwright_format_comprehensive(): + """Comprehensive test of Playwright format with real BrightData API.""" + api_key = os.getenv("BRIGHTDATA_API_KEY") + provider = BrightData(api_key=api_key, zone="static") + + proxy = provider.get_proxy() + + # Test 1: Default Playwright format + default_format = proxy.format(ProxyFormat.PLAYWRIGHT) + assert isinstance(default_format, dict) + assert "server" in default_format + assert "username" in default_format + assert "password" in default_format + assert default_format["server"].startswith("http://") + + # Test 2: Protocol variations + protocols_to_test = ["http", "https"] + + for protocol in protocols_to_test: + result = proxy.format(ProxyFormat.PLAYWRIGHT, protocol=protocol) + assert result["server"].startswith(f"{protocol}://") + assert result["username"] == proxy.username + assert result["password"] == proxy.password + + # Test 3: String format + string_format = proxy.format("playwright") + assert string_format == default_format + + # Test 4: Format consistency + enum_format = proxy.format(ProxyFormat.PLAYWRIGHT) + string_format = proxy.format("playwright") + assert enum_format == string_format + + # Test 5: Playwright documentation compliance + playwright_config = proxy.format(ProxyFormat.PLAYWRIGHT) + + # Verify structure matches Playwright docs + required_fields = ["server"] + optional_fields = ["username", "password"] + + for field in required_fields: + assert field in playwright_config, f"Missing required field: {field}" + + for field in optional_fields: + if field in playwright_config: + assert True # Field is present + else: + assert False, f"Missing optional field: {field}" + + # Verify server format + server = playwright_config["server"] + assert "://" in server, "Server should include protocol" + assert ( + f"{proxy.proxy_address}:{proxy.port}" in server + ), "Server should include address and port" + + # Test 6: Protocol fallback behavior + # Try to use an unsupported protocol - should fallback gracefully + fallback_result = proxy.format(ProxyFormat.PLAYWRIGHT, protocol="socks5") + assert "server" in fallback_result + assert fallback_result["server"].startswith("http://") # Should fallback to http + @skip_integration def test_brightdata_e2e_with_requests(): diff --git a/tests/test_proxy_model.py b/tests/test_proxy_model.py index d089d26..0a5a99a 100644 --- a/tests/test_proxy_model.py +++ b/tests/test_proxy_model.py @@ -141,6 +141,114 @@ def test_format_aiohttp(self, sample_proxy): expected = "http://testuser:testpass@192.168.1.100:8080" assert result == expected + def test_format_playwright_with_auth(self, sample_proxy): + """Test format for Playwright with authentication.""" + result = sample_proxy.format(ProxyFormat.PLAYWRIGHT) + expected = { + "server": "http://192.168.1.100:8080", + "username": "testuser", + "password": "testpass", + } + assert result == expected + + def test_format_playwright_without_auth(self, sample_proxy_no_protocols): + """Test format for Playwright with authentication (sample_proxy_no_protocols has auth).""" + result = sample_proxy_no_protocols.format(ProxyFormat.PLAYWRIGHT) + expected = { + "server": "http://10.0.0.1:3128", + "username": "user2", + "password": "pass2", + } + assert result == expected + + def test_format_playwright_empty_credentials(self): + """Test format for Playwright with empty username/password.""" + proxy = Proxy( + id="no-auth", + username="", + password="", + proxy_address="192.168.1.1", + port=8080, + ) + result = proxy.format(ProxyFormat.PLAYWRIGHT) + expected = {"server": "http://192.168.1.1:8080"} + assert result == expected + + def test_format_playwright_none_credentials(self): + """Test format for Playwright with None username/password.""" + proxy = Proxy( + id="none-auth", + username=None, + password=None, + proxy_address="192.168.1.1", + port=8080, + ) + result = proxy.format(ProxyFormat.PLAYWRIGHT) + expected = {"server": "http://192.168.1.1:8080"} + assert result == expected + + def test_format_playwright_string_format(self, sample_proxy): + """Test format for Playwright using string format.""" + result = sample_proxy.format("playwright") + expected = { + "server": "http://192.168.1.100:8080", + "username": "testuser", + "password": "testpass", + } + assert result == expected + + def test_format_playwright_with_protocol(self, sample_proxy): + """Test format for Playwright with specific protocol.""" + # Test with https protocol + result = sample_proxy.format(ProxyFormat.PLAYWRIGHT, protocol="https") + expected = { + "server": "https://192.168.1.100:8080", + "username": "testuser", + "password": "testpass", + } + assert result == expected + + def test_format_playwright_with_socks5(self): + """Test format for Playwright with SOCKS5 protocol.""" + # Create a proxy that supports SOCKS5 + proxy = Proxy( + id="socks5-test", + username="testuser", + password="testpass", + proxy_address="192.168.1.100", + port=8080, + protocols=["http", "https", "socks5"], # Include socks5 support + ) + + result = proxy.format(ProxyFormat.PLAYWRIGHT, protocol="socks5") + expected = { + "server": "socks5://192.168.1.100:8080", + "username": "testuser", + "password": "testpass", + } + assert result == expected + + def test_format_playwright_protocol_fallback(self): + """Test format for Playwright with protocol fallback.""" + # Create proxy with limited protocols + proxy = Proxy( + id="limited-protocol", + username="user", + password="pass", + proxy_address="192.168.1.1", + port=8080, + protocols=["http"], # Only supports HTTP + ) + + # Request unsupported protocol, should fallback to http + result = proxy.format(ProxyFormat.PLAYWRIGHT, protocol="socks5") + expected = { + "server": "http://192.168.1.1:8080", + "username": "user", + "password": "pass", + } + assert result == expected + def test_format_string_enum(self, sample_proxy): """Test format with string instead of enum.""" result = sample_proxy.format("requests") @@ -152,9 +260,65 @@ def test_format_string_enum(self, sample_proxy): def test_format_unsupported_format(self, sample_proxy): """Test format with unsupported format type.""" - with pytest.raises(ValueError, match="is not a valid ProxyFormat"): + with pytest.raises(ValueError, match="Invalid format type"): sample_proxy.format("unsupported") + def test_format_invalid_type_not_string_or_enum(self, sample_proxy): + """Test format with invalid type (not string or ProxyFormat enum).""" + with pytest.raises( + ValueError, + match="Invalid format type: int. Expected ProxyFormat enum or string.", + ): + sample_proxy.format(123) # int instead of string/enum + + def test_format_invalid_type_none(self, sample_proxy): + """Test format with None type.""" + with pytest.raises( + ValueError, + match="Invalid format type: NoneType. Expected ProxyFormat enum or string.", + ): + sample_proxy.format(None) + + def test_format_invalid_type_list(self, sample_proxy): + """Test format with list type.""" + with pytest.raises( + ValueError, + match="Invalid format type: list. Expected ProxyFormat enum or string.", + ): + sample_proxy.format(["requests"]) + + def test_format_invalid_type_dict(self, sample_proxy): + """Test format with dict type.""" + with pytest.raises( + ValueError, + match="Invalid format type: dict. Expected ProxyFormat enum or string.", + ): + sample_proxy.format({"format": "requests"}) + + def test_format_handlers_complete_coverage(self, sample_proxy): + """Test that all ProxyFormat enum values have corresponding handlers.""" + # This test ensures that if someone adds a new ProxyFormat enum value, + # they must also add a corresponding handler in the format_handlers dictionary + + # Get all ProxyFormat enum values + all_proxy_formats = set(ProxyFormat) + + # Test that each format can be processed without KeyError + for format_type in all_proxy_formats: + try: + result = sample_proxy.format(format_type) + assert ( + result is not None + ), f"Format {format_type.value} should return a result" + except KeyError as e: + pytest.fail(f"Missing handler for ProxyFormat.{format_type.name}: {e}") + except Exception as e: + # Other exceptions are fine (like validation errors), but KeyError means missing handler + if "KeyError" in str(type(e)): + pytest.fail( + f"Missing handler for ProxyFormat.{format_type.name}: {e}" + ) + def test_format_proxy_with_limited_protocols(self): """Test format with proxy that only supports specific protocols.""" proxy = Proxy( @@ -237,3 +401,7 @@ def test_all_format_types_work(self, sample_proxy): # AIOHTTP format returns string aiohttp_result = sample_proxy.format(ProxyFormat.AIOHTTP) assert isinstance(aiohttp_result, str) + + # PLAYWRIGHT format returns dict + playwright_result = sample_proxy.format(ProxyFormat.PLAYWRIGHT) + assert isinstance(playwright_result, dict)