diff --git a/src/ocrmypdf/leptonica.py b/src/ocrmypdf/leptonica.py index 336c00ff..af69129f 100644 --- a/src/ocrmypdf/leptonica.py +++ b/src/ocrmypdf/leptonica.py @@ -27,15 +27,16 @@ from tempfile import TemporaryFile from ocrmypdf.exceptions import MissingDependencyError from ocrmypdf.lib._leptonica import ffi -from ocrmypdf.subprocess import shim_paths_with_program_files # pylint: disable=protected-access logger = logging.getLogger(__name__) if os.name == 'nt': + from ocrmypdf.subprocess._windows import shim_env_path + libname = 'liblept-5' - os.environ['PATH'] = shim_paths_with_program_files() + os.environ['PATH'] = shim_env_path() else: libname = 'lept' _libpath = find_library(libname) @@ -58,9 +59,9 @@ if not _libpath: --------------------------------------------------------------------- """ ) -if os.name == 'nt': - # On Windows, recent versions of libpng require zlib. We have to make sure - # the zlib version being loaded is the same one that libpng was built with. +if os.name == 'nt': + # On Windows, recent versions of libpng require zlib. We have to make sure + # the zlib version being loaded is the same one that libpng was built with. # This tries to import zlib from Tesseract's installation folder, falling back # to find_library() if liblept is being loaded from somewhere else. # Loading zlib from other places could cause a version mismatch diff --git a/src/ocrmypdf/subprocess.py b/src/ocrmypdf/subprocess/__init__.py similarity index 84% rename from src/ocrmypdf/subprocess.py rename to src/ocrmypdf/subprocess/__init__.py index 1d98a747..e68f8b0c 100644 --- a/src/ocrmypdf/subprocess.py +++ b/src/ocrmypdf/subprocess/__init__.py @@ -10,7 +10,6 @@ import logging import os import re -import shutil import sys from collections.abc import Mapping from contextlib import suppress @@ -104,7 +103,9 @@ def _fix_process_args(args, env, kwargs): program = args[0] if os.name == 'nt': - args = _fix_windows_args(program, args, env) + from ocrmypdf.subprocess._windows import fix_windows_args + + args = fix_windows_args(program, args, env) log.debug("Running: %s", args) process_log = log.getChild(os.path.basename(program)) @@ -121,30 +122,6 @@ def _fix_process_args(args, env, kwargs): return args, env, process_log, text -def _fix_windows_args(program, args, env): - """Adjust our desired program and command line arguments for use on Windows""" - - if sys.version_info < (3, 8): - # bpo-33617 - Windows needs manual Path -> str conversion - args = [os.fspath(arg) for arg in args] - program = os.fspath(program) - - # If we are running a .py on Windows, ensure we call it with this Python - # (to support test suite shims) - if program.lower().endswith('.py'): - args = [sys.executable] + args - - paths = os.pathsep.join(os.get_exec_path(env)) - if not shutil.which(args[0], path=paths): - # If the program we want is not on the PATH, add some interesting - # locations in %PROGRAMFILES% to the PATH and try again - shimmed_path = shim_paths_with_program_files(env) - new_args0 = shutil.which(args[0], path=shimmed_path) - if new_args0: - args[0] = new_args0 - return args - - @lru_cache(maxsize=None) def get_version( program: str, *, version_arg: str = '--version', regex=r'(\d+(\.\d+)*)', env=None @@ -193,33 +170,6 @@ def get_version( return version -def shim_paths_with_program_files(env=None): - if not env: - env = os.environ - program_files = env.get('PROGRAMFILES', '') - if not program_files: - return env.get('PATH', '') - - def path_walker(): - for path in Path(program_files).iterdir(): - if not path.is_dir(): - continue - if path.name.lower() == 'tesseract-ocr': - yield path - elif path.name.lower() == 'gs': - yield from (p for p in path.glob('**/bin') if p.is_dir()) - - paths = sorted( - (p for p in path_walker()), key=lambda p: (p.name, p.parent.name), reverse=True - ) - paths.extend( - Path(str_path) - for str_path in os.get_exec_path(env) - if Path(str_path) not in set(paths) - ) - return os.pathsep.join(str(p) for p in paths) - - missing_program = ''' The program '{program}' could not be executed or was not found on your system PATH. diff --git a/src/ocrmypdf/subprocess/_windows.py b/src/ocrmypdf/subprocess/_windows.py new file mode 100644 index 00000000..aec082b7 --- /dev/null +++ b/src/ocrmypdf/subprocess/_windows.py @@ -0,0 +1,162 @@ +# © 2020 James R. Barlow: github.com/jbarlow83 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import logging +import os +import shutil +import sys +from distutils.version import LooseVersion +from itertools import chain, filterfalse +from pathlib import Path +from typing import Any, Callable, Iterator, Optional, Tuple, TypeVar, cast + +try: + import winreg +except ModuleNotFoundError as e: + raise ModuleNotFoundError("This module is for Windows only") from e + +log = logging.getLogger(__name__) + +T = TypeVar('T') + + +def registry_enum( + key: winreg.HKEYType, enum_fn: Callable[[winreg.HKEYType, int], T] +) -> Iterator[T]: + LIMIT = 999 + n = 0 + while n < LIMIT: + try: + yield enum_fn(key, n) + n += 1 + except OSError: + break + if n == LIMIT: + raise ValueError(f"Too many registry keys under {key}") + + +def registry_subkeys(key: winreg.HKEYType) -> Iterator[str]: + return registry_enum(key, winreg.EnumKey) + + +def registry_values(key: winreg.HKEYType) -> Iterator[Tuple[str, Any, int]]: + return registry_enum(key, winreg.EnumValue) + + +def registry_path_ghostscript(env=None) -> Iterator[Path]: + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Artifex\GPL Ghostscript" + ) as k: + latest_gs = max(registry_subkeys(k), key=LooseVersion) + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, fr"SOFTWARE\Artifex\GPL Ghostscript\{latest_gs}" + ) as k: + _, gs_path, _ = next(registry_values(k)) + yield Path(gs_path) / 'bin' + except OSError as e: + log.warning(e) + + +def registry_path_tesseract(env=None) -> Iterator[Path]: + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Tesseract-OCR") as k: + for subkey, val, _valtype in registry_values(k): + if subkey == 'InstallDir': + tesseract_path = Path(val) + yield tesseract_path + except OSError as e: + log.warning(e) + + +def program_files_paths(env=None) -> Iterator[Path]: + if not env: + env = os.environ + program_files = env.get('PROGRAMFILES', '') + + def path_walker() -> Iterator[Path]: + for path in Path(program_files).iterdir(): + if not path.is_dir(): + continue + if path.name.lower() == 'tesseract-ocr': + yield path + elif path.name.lower() == 'gs': + yield from (p for p in path.glob('**/bin') if p.is_dir()) + + return iter( + sorted( + (p for p in path_walker()), + key=lambda p: (p.name, p.parent.name), + reverse=True, + ) + ) + + +def paths_from_env(env=None) -> Iterator[Path]: + return (Path(p) for p in os.get_exec_path(env) if p) + + +def shim_path(new_paths: Callable[[Any], Iterator[Path]], env=None) -> str: + if not env: + env = os.environ + return os.pathsep.join(str(p) for p in new_paths(env) if p) + + +SHIMS = [ + paths_from_env, + registry_path_ghostscript, + registry_path_tesseract, + program_files_paths, +] + + +def fix_windows_args(program, args, env): + """Adjust our desired program and command line arguments for use on Windows""" + + if sys.version_info < (3, 8): + # bpo-33617 - Windows needs manual Path -> str conversion + args = [os.fspath(arg) for arg in args] + program = os.fspath(program) + + # If we are running a .py on Windows, ensure we call it with this Python + # (to support test suite shims) + if program.lower().endswith('.py'): + args = [sys.executable] + args + + # If the program we want is not on the PATH, check elsewhere + for shim in SHIMS: + shimmed_path = shim_path(shim, env) + new_args0 = shutil.which(args[0], path=shimmed_path) + if new_args0: + args[0] = new_args0 + break + + return args + + +def unique_everseen(iterable, key=None): + "List unique elements, preserving order. Remember all elements ever seen." + # unique_everseen('AAAABBBCCDAABBB') --> A B C D + # unique_everseen('ABBCcAD', str.lower) --> A B C D + seen = set() + seen_add = seen.add + if key is None: + key = lambda x: x + for element in iterable: + k = key(element) + if k not in seen: + seen_add(k) + yield element + + +def shim_env_path(env=None): + if env is None: + env = os.environ + + shim_paths = chain.from_iterable(shim(env) for shim in SHIMS) + return os.pathsep.join( + str(p) for p in unique_everseen(shim_paths, key=lambda p: str.casefold(str(p))) + ) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fe1e5513..6d5e358c 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -13,7 +13,6 @@ from unittest.mock import MagicMock import pytest from ocrmypdf import helpers as helpers -from ocrmypdf.subprocess import shim_paths_with_program_files class TestSafeSymlink: @@ -94,7 +93,10 @@ class TestFileIsWritable: assert not helpers.is_file_writable(pathmock) +@pytest.mark.skipif(os.name != 'nt', reason="Windows test") def test_shim_paths(tmp_path): + from ocrmypdf.subprocess._windows import shim_env_path + progfiles = tmp_path / 'Program Files' progfiles.mkdir() (progfiles / 'tesseract-ocr').mkdir() @@ -103,9 +105,9 @@ def test_shim_paths(tmp_path): syspath = tmp_path / 'bin' env = {'PROGRAMFILES': str(progfiles), 'PATH': str(syspath)} - result_str = shim_paths_with_program_files(env=env) + result_str = shim_env_path(env=env) results = result_str.split(os.pathsep) - assert results[0].endswith('tesseract-ocr') - assert results[1].endswith(os.path.join('gs', '9.52', 'bin')) - assert results[2].endswith(os.path.join('gs', '9.51', 'bin')) - assert results[3] == str(syspath) + assert results[0] == str(syspath), results + assert results[-3].endswith('tesseract-ocr'), results + assert results[-2].endswith(os.path.join('gs', '9.52', 'bin')), results + assert results[-1].endswith(os.path.join('gs', '9.51', 'bin')), results