diff --git a/html2pdf4doc/main.py b/html2pdf4doc/main.py index 8c6e46b..8bb6228 100644 --- a/html2pdf4doc/main.py +++ b/html2pdf4doc/main.py @@ -118,12 +118,20 @@ def __str__(self) -> str: class ChromeDriverManager: def get_chrome_driver( - self, path_to_cache_dir: str, verify_ssl: bool = True + self, + path_to_cache_dir: str, + verify_ssl: bool = True, + chrome_binary: Optional[str] = None, ) -> str: - chrome_version: Optional[str] = self.get_chrome_version() + chrome_version: Optional[str] = self.get_chrome_version(chrome_binary) # If Web Driver Manager cannot detect Chrome, it returns None. if chrome_version is None: + if chrome_binary is not None: + raise HPDError( + f"Could not determine the Chrome version from --chrome-binary: {chrome_binary!r}.", + exit_code=HPDExitCode.COULD_NOT_FIND_CHROME, + ) raise HPDError( "Web Driver Manager could not detect an existing Chrome installation.", exit_code=HPDExitCode.COULD_NOT_FIND_CHROME, @@ -296,7 +304,30 @@ def send_http_get_request(url: str, verify_ssl: bool = True) -> Response: ) from last_error @staticmethod - def get_chrome_version() -> Optional[str]: + def _probe_chrome_version(chrome_binary: str) -> str: + # Shared by the macOS special case below and --chrome-binary. + version_output = subprocess.run( + [chrome_binary, "--version"], + capture_output=True, + text=True, + check=True, + ) + chrome_version = version_output.stdout.strip() + match = re.search(r"\d+(\.\d+)+", chrome_version) + if not match: + raise RuntimeError("Cannot extract the version part using regex.") + return match.group(0) + + @staticmethod + def get_chrome_version( + chrome_binary: Optional[str] = None, + ) -> Optional[str]: + if chrome_binary is not None: + try: + return ChromeDriverManager._probe_chrome_version(chrome_binary) + except (OSError, subprocess.CalledProcessError, RuntimeError): + return None + # Special case: GitHub Actions macOS CI machines have both # Google Chrome for Testing and normal Google Chrome installed, and # sometimes their versions are of different major version families. @@ -311,20 +342,9 @@ def get_chrome_version() -> Optional[str]: "a normal Chrome available." ) - version_output = subprocess.run( - [chrome_path, "--version"], - capture_output=True, - text=True, - check=True, + chrome_version = ChromeDriverManager._probe_chrome_version( + chrome_path ) - chrome_version = version_output.stdout.strip() - match = re.search(r"\d+(\.\d+)+", chrome_version) - if not match: - raise RuntimeError( - "Cannot extract the version part using regex." - ) - - chrome_version = match.group(0) print( # noqa: T201 f"html2pdf4doc: Google Chrome for Testing Version: {chrome_version}" @@ -462,13 +482,16 @@ def create_webdriver( page_load_timeout: int, verify_ssl: bool = True, debug: bool = False, + chrome_binary: Optional[str] = None, ) -> webdriver.Chrome: print("html2pdf4doc: Creating ChromeDriver service.", flush=True) # noqa: T201 path_to_chrome_driver: str if chromedriver_argument is None: path_to_chrome_driver = chrome_driver_manager.get_chrome_driver( - path_to_cache_dir, verify_ssl=verify_ssl + path_to_cache_dir, + verify_ssl=verify_ssl, + chrome_binary=chrome_binary, ) else: path_to_chrome_driver = chromedriver_argument @@ -484,6 +507,8 @@ def create_webdriver( service = Service(path_to_chrome_driver) webdriver_options = Options() + if chrome_binary is not None: + webdriver_options.binary_location = chrome_binary webdriver_options.add_argument("start-maximized") webdriver_options.add_argument("disable-infobars") # Doesn't seem to be needed. @@ -591,6 +616,11 @@ def _main() -> None: "By default SSL certificate verification is enabled." ), ) + command_parser_get_driver.add_argument( + "--chrome-binary", + type=str, + help="Optional path to a Chrome/Chromium binary. Falls back to $HTML2PDF4DOC_CHROME_BINARY, then auto-detection.", + ) # # Print command. @@ -649,6 +679,11 @@ def _main() -> None: "message is printed and the execution continues." ), ) + command_parser_print.add_argument( + "--chrome-binary", + type=str, + help="Optional path to a Chrome/Chromium binary. Falls back to $HTML2PDF4DOC_CHROME_BINARY, then auto-detection.", + ) command_parser_print.add_argument( "--strict2", action="store_true", @@ -665,6 +700,10 @@ def _main() -> None: args = parser.parse_args() + chrome_binary: Optional[str] = args.chrome_binary or os.environ.get( + "HTML2PDF4DOC_CHROME_BINARY" + ) + chrome_driver_manager = ChromeDriverManager() path_to_cache_dir: str @@ -676,6 +715,7 @@ def _main() -> None: path_to_chrome = chrome_driver_manager.get_chrome_driver( path_to_cache_dir, verify_ssl=not args.disable_ssl_check, + chrome_binary=chrome_binary, ) print(f"html2pdf4doc: ChromeDriver available at path: {path_to_chrome}") # noqa: T201 sys.exit(0) @@ -695,6 +735,7 @@ def _main() -> None: page_load_timeout, verify_ssl=not args.disable_ssl_check, debug=args.debug, + chrome_binary=chrome_binary, ) @atexit.register diff --git a/tests/unit/test_chrome_driver_manager.py b/tests/unit/test_chrome_driver_manager.py index 574f341..baca02a 100644 --- a/tests/unit/test_chrome_driver_manager.py +++ b/tests/unit/test_chrome_driver_manager.py @@ -1,3 +1,4 @@ +import subprocess import tempfile from typing import Any, Dict, Optional @@ -10,7 +11,10 @@ class FailingChromeDriverManager(ChromeDriverManager): @staticmethod - def get_chrome_version() -> Optional[str]: + def get_chrome_version( + chrome_binary: Optional[str] = None, + ) -> Optional[str]: + del chrome_binary return None @@ -121,3 +125,67 @@ def fake_get(*args: Any, **kwargs: Any) -> requests.Response: ChromeDriverManager.send_http_get_request("https://example.com") assert "--disable-ssl-check" in str(exc_info.value) + + +def test_get_chrome_version_probes_explicit_binary_directly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_run(*args: Any, **kwargs: Any) -> Any: + del kwargs + assert args[0] == ["/opt/my-chrome/chrome", "--version"] + return subprocess.CompletedProcess( + args=args[0], + returncode=0, + stdout="Google Chrome for Testing 152.0.7977.82\n", + ) + + def fail_if_called(*args: Any, **kwargs: Any) -> Any: + del args, kwargs + raise AssertionError( + "OS auto-detection must not run when --chrome-binary is given" + ) + + monkeypatch.setattr("html2pdf4doc.main.subprocess.run", fake_run) + monkeypatch.setattr( + "html2pdf4doc.main.OperationSystemManager.get_browser_version_from_os", + fail_if_called, + ) + + version = ChromeDriverManager.get_chrome_version("/opt/my-chrome/chrome") + + assert version == "152.0.7977.82" + + +def test_get_chrome_version_returns_none_when_binary_is_unusable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_run(*args: Any, **kwargs: Any) -> Any: + del args, kwargs + raise FileNotFoundError("no such file") + + monkeypatch.setattr("html2pdf4doc.main.subprocess.run", fake_run) + + version = ChromeDriverManager.get_chrome_version("/does/not/exist") + + assert version is None + + +def test_get_chrome_driver_reports_the_bad_binary_path_when_given( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_run(*args: Any, **kwargs: Any) -> Any: + del args, kwargs + raise FileNotFoundError("no such file") + + monkeypatch.setattr("html2pdf4doc.main.subprocess.run", fake_run) + + chrome_driver_manager = ChromeDriverManager() + + with tempfile.TemporaryDirectory() as tmpdir: + with pytest.raises(HPDError) as exc_info: + chrome_driver_manager.get_chrome_driver( + tmpdir, chrome_binary="/does/not/exist" + ) + + assert exc_info.value.exit_code == HPDExitCode.COULD_NOT_FIND_CHROME + assert "/does/not/exist" in str(exc_info.value)