diff --git a/.github/actions/test-desktop-tool/action.yml b/.github/actions/test-desktop-tool/action.yml index f412652b4..2b2df5dc3 100644 --- a/.github/actions/test-desktop-tool/action.yml +++ b/.github/actions/test-desktop-tool/action.yml @@ -2,8 +2,9 @@ name: Desktop tool tests description: Test the MPC Autofill CLI / desktop tool inputs: google-drive-api-key: - description: Your Google Drive API key, required for running the database crawler - required: true + description: Your Google Drive API key. When empty, tests requiring it skip themselves. + required: false + default: "" runs: using: composite steps: @@ -30,6 +31,7 @@ runs: pip install -r requirements.txt shell: bash - name: Write Google Drive API credentials to file + if: inputs.google-drive-api-key != '' uses: jsdaniell/create-json@v1.2.3 with: name: "client_secrets.json" @@ -37,5 +39,12 @@ runs: dir: "desktop-tool/" - name: Run tests working-directory: desktop-tool - run: pytest . + env: + MPC_AUTOFILL_RELEASE_CHECKS: "1" + run: | + if [[ "$RUNNER_OS" == Linux ]]; then + dbus-run-session -- pytest . + else + pytest . + fi shell: bash diff --git a/.github/workflows/build-desktop-tool.yml b/.github/workflows/build-desktop-tool.yml new file mode 100644 index 000000000..0ef8659ca --- /dev/null +++ b/.github/workflows/build-desktop-tool.yml @@ -0,0 +1,101 @@ +name: Desktop tool build + +on: + workflow_dispatch: + inputs: + version: + description: Release version embedded in the executable (for example, 1.1.0) + required: true + type: string + +jobs: + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - macos-latest + - ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/test-desktop-tool + with: + google-drive-api-key: ${{ secrets.GOOGLE_DRIVE_API_KEY }} + + build: + name: Build ${{ matrix.target }} + needs: test + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: desktop-tool + strategy: + matrix: + include: + - os: macos-latest + target: macos-arm + filename: autofill-macos-arm.command + macos-arch: arm64 + - os: macos-15-intel + target: macos-intel + filename: autofill-macos-intel.command + macos-arch: x86_64 + - os: ubuntu-latest + target: linux + filename: autofill-linux.bin + macos-arch: "" + - os: windows-latest + target: windows + filename: autofill-windows.exe + macos-arch: "" + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.13 + uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + python -m pip install imageio + - name: Require Google Drive API credentials + env: + GOOGLE_DRIVE_API_KEY: ${{ secrets.GOOGLE_DRIVE_API_KEY }} + shell: bash + run: test -n "$GOOGLE_DRIVE_API_KEY" || (echo "Add desktop-tool/client_secrets.json as the GOOGLE_DRIVE_API_KEY repository secret." && exit 1) + - name: Write Google Drive API credentials to file + uses: jsdaniell/create-json@v1.2.3 + with: + name: client_secrets.json + json: ${{ secrets.GOOGLE_DRIVE_API_KEY }} + dir: desktop-tool/ + - name: Build with Nuitka + uses: Nuitka/Nuitka-Action@main + with: + nuitka-version: main + working-directory: desktop-tool + script-name: autofill.py + mode: onefile + output-file: ${{ matrix.filename }} + # required by the versioned onefile cache directory ({VERSION} in --onefile-tempdir-spec) + file-version: ${{ inputs.version }} + macos-target-arch: ${{ matrix.macos-arch }} + - name: Smoke test bundled executable + shell: bash + run: | + env -u SSL_CERT_FILE -u SSL_CERT_DIR ./build/${{ matrix.filename }} --check-tls + if [[ "$RUNNER_OS" == Linux ]]; then + help_output=$(dbus-run-session -- ./build/${{ matrix.filename }} --help) + else + help_output=$(./build/${{ matrix.filename }} --help) + fi + grep -q MakePlayingCards <<< "$help_output" + grep -q DriveThruCards <<< "$help_output" + stat -f "%N: %z bytes" build/${{ matrix.filename }} 2>/dev/null || stat -c "%n: %s bytes" build/${{ matrix.filename }} + - name: Upload unsigned artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.target }} Unsigned Build + path: desktop-tool/build/${{ matrix.filename }} diff --git a/.github/workflows/desktop-tool-ci.yml b/.github/workflows/desktop-tool-ci.yml index 3d15f83b1..29ba509ba 100644 --- a/.github/workflows/desktop-tool-ci.yml +++ b/.github/workflows/desktop-tool-ci.yml @@ -114,12 +114,17 @@ jobs: script-name: autofill.py mode: onefile output-file: ${{ matrix.FILENAME }} + # required by the versioned onefile cache directory ({VERSION} in --onefile-tempdir-spec) + file-version: 1.0.${{ github.run_number }} macos-sign-notarization: ${{ (matrix.TARGET == 'macos-arm') || (matrix.TARGET == 'macos-intel') }} macos-sign-identity: ${{ secrets.MACOS_SIGN_IDENTITY }} macos-sign-keyring-filename: "${{ runner.temp }}/app-signing.keychain-db" macos-sign-keyring-password: ${{ secrets.KEYCHAIN_PASSWORD }} macos-signed-app-name: com.ndepaola.mpc-autofill-${{ matrix.TARGET }} macos-target-arch: ${{ matrix.MACOS_ARCH }} + - name: Smoke test bundled TLS + shell: bash + run: env -u SSL_CERT_FILE -u SSL_CERT_DIR ./build/${{ matrix.FILENAME }} --check-tls - name: Upload unsigned artifact id: upload-unsigned-artifact uses: actions/upload-artifact@v7 diff --git a/.github/workflows/test-desktop-tool.yml b/.github/workflows/test-desktop-tool.yml index 52acbfadf..4a43ba15a 100644 --- a/.github/workflows/test-desktop-tool.yml +++ b/.github/workflows/test-desktop-tool.yml @@ -8,6 +8,8 @@ jobs: test: name: Desktop tool tests runs-on: ${{ matrix.os }} + env: + GOOGLE_DRIVE_API_KEY: ${{ secrets.GOOGLE_DRIVE_API_KEY }} strategy: matrix: include: @@ -16,6 +18,12 @@ jobs: # - os: ubuntu-latest # https://github.com/browser-actions/setup-edge/issues/516 steps: - uses: actions/checkout@v6 - - uses: ./.github/actions/test-desktop-tool + # Tests requiring the Google Drive API key skip themselves when it's absent (fork pull + # requests), so the rest of the suite still runs. + - name: Run desktop tool tests + uses: ./.github/actions/test-desktop-tool with: - google-drive-api-key: ${{ secrets.GOOGLE_DRIVE_API_KEY }} + google-drive-api-key: ${{ env.GOOGLE_DRIVE_API_KEY }} + - name: Note skipped secret-backed tests for fork pull requests + if: env.GOOGLE_DRIVE_API_KEY == '' + run: echo "::notice::Tests requiring GOOGLE_DRIVE_API_KEY were skipped because the secret is unavailable to fork pull requests." diff --git a/.gitignore b/.gitignore index 0c0ff9e9f..f11e620d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Ignore XML files we might use for debugging cards.xml -cards/* +**/cards/* # Ignore pyinstaller stuff __pycache__/* @@ -18,6 +18,20 @@ __pycache__/ # C extensions *.so +# Ignore Nuitka package stuff +**/autofill.dist/* +**/autofill.build/* +**/autofill.onefile-build/* +**/autofill.bin +autofill.command +autofill.exe +autofill-macos-arm.command +autofill-macos-intel.command +autofill-windows.exe +autofill-linux.bin +nuitka-crash-report.xml +autofill_crash_log.txt + # Distribution / packaging .Python build/ @@ -57,6 +71,7 @@ coverage.xml .pytest_cache/ desktop-tool/export/ desktop-tool/tests/export/ +desktop-tool/tests/cards/ cover/ @@ -94,7 +109,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -115,6 +130,7 @@ celerybeat.pid # Environments .env +.login .venv env/ venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e6763d784..72296f615 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,6 +25,7 @@ repos: rev: v1.7.0 hooks: - id: mypy + language_version: python3.13 args: [--config-file, mypy.ini, desktop-tool/, MPCAutofill/] additional_dependencies: [ # typing diff --git a/desktop-tool/assets/placeholder_cover.png b/desktop-tool/assets/placeholder_cover.png new file mode 100644 index 000000000..5989e1f26 Binary files /dev/null and b/desktop-tool/assets/placeholder_cover.png differ diff --git a/desktop-tool/autofill.py b/desktop-tool/autofill.py index bd82abc61..97b33dd8c 100644 --- a/desktop-tool/autofill.py +++ b/desktop-tool/autofill.py @@ -1,6 +1,13 @@ # nuitka-project: --mode=onefile +# Each build version extracts to its own cache directory: rewriting a previously-run executable +# in place invalidates macOS's cached code-signature and the OS kills the process with SIGKILL. +# {VERSION} makes builds fail loudly unless --file-version (or --product-version) is passed. +# nuitka-project: --onefile-tempdir-spec={CACHE_DIR}/mpc-autofill/{VERSION} # nuitka-project: --include-data-files=client_secrets.json=client_secrets.json # nuitka-project: --include-data-files=post-launch.html=post-launch.html +# nuitka-project: --include-data-files=dtc-post-launch.html=dtc-post-launch.html +# nuitka-project: --include-data-dir=assets=assets +# nuitka-project: --include-package-data=certifi # nuitka-project: --noinclude-pytest-mode=nofollow # nuitka-project: --windows-icon-from-ico=favicon.ico # nuitka-project-if: {OS} == "Windows": @@ -14,37 +21,390 @@ # nuitka-project: --noinclude-data-files=selenium/webdriver/common/windows/selenium-manager.exe # nuitka-project: --noinclude-data-files=selenium/webdriver/common/macos/selenium-manager +from __future__ import annotations import logging import os +import shutil +import subprocess import sys from contextlib import nullcontext -from typing import Optional, Union +from glob import glob +from typing import TYPE_CHECKING, Optional +import certifi import click -from wakepy import keepawake +from click.core import ParameterSource +from InquirerPy import inquirer -from src.constants import Browsers, ImageResizeMethods, TargetSites -from src.driver import AutofillDriver -from src.exc import ValidationException +from src.constants import ( + DTC_POST_LAUNCH_HTML_FILENAME, + Browsers, + ImageResizeMethods, + TargetSites, +) from src.formatting import bold -from src.io import DEFAULT_WORKING_DIRECTORY, create_image_directory_if_not_exists -from src.logging import configure_loggers, logger -from src.order import CardOrder, aggregate_and_split_orders -from src.pdf_maker import PdfExporter -from src.processing import ImagePostProcessingConfig -from src.web_server import WebServer +from src.logging import logger + +if TYPE_CHECKING: + from src.order import CardOrder + from src.processing import ImagePostProcessingConfig + + +def configure_tls() -> str: + """Use the bundled CA bundle unless the user supplied one explicitly.""" + + return os.environ.setdefault("SSL_CERT_FILE", certifi.where()) + + +def prune_stale_onefile_caches(extraction_directory: str) -> None: + """ + Each build version extracts to its own directory under the mpc-autofill cache directory + (see the --onefile-tempdir-spec build directive) - delete extractions left by old versions. + """ + + parent_directory = os.path.dirname(extraction_directory) + if os.path.basename(parent_directory) != "mpc-autofill": + return + for entry in os.listdir(parent_directory): + sibling = os.path.join(parent_directory, entry) + if sibling != extraction_directory and os.path.isdir(sibling): + shutil.rmtree(sibling, ignore_errors=True) + + +configure_tls() +if "__compiled__" in globals(): + prune_stale_onefile_caches(os.path.dirname(os.path.abspath(__file__))) # https://stackoverflow.com/questions/12492810/python-how-can-i-make-the-ansi-escape-codes-to-work-also-in-windows -os.system("") # enables ansi escape characters in terminal +if sys.platform == "win32": + os.system("") # enables ansi escape characters in the Windows terminal + +DEFAULT_BROWSER = Browsers.chrome.name +DEFAULT_SITE = TargetSites.MakePlayingCards.name +DEFAULT_AUTO_SAVE = True +DEFAULT_IMAGE_POST_PROCESSING = True +TLS_CHECK_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json" +GHOSTSCRIPT_DOWNLOAD_PAGE = "https://ghostscript.com/releases/gsdnld.html" +GHOSTSCRIPT_VERSION = "10.07.1" +GHOSTSCRIPT_WINDOWS_INSTALLERS = { + "w32.exe": ( + "https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs10071/gs10071w32.exe", + "2dc44e339e2a50d96827e199a234713604ac04a5cec2e07bd452cc92e8d6f81b", + ), + "w64.exe": ( + "https://github.com/ArtifexSoftware/ghostpdl-downloads/releases/download/gs10071/gs10071w64.exe", + "3a4c28d0aac47aa7cccd35a5932c55110376e9dbd966898dde388b7faba444a4", + ), +} + + +def get_browser_picker_choices() -> list[str]: + return sorted(browser.name for browser in Browsers) + + +def get_site_picker_choices() -> list[str]: + return [site.name for site in TargetSites] + + +def should_run_interactive_onboarding() -> bool: + return len(sys.argv) == 1 and sys.stdin.isatty() and sys.stdout.isatty() + + +def run_interactive_onboarding() -> tuple[str, str, bool, bool]: + browser = inquirer.rawlist( + message="Which web browser should the tool run on? (Press Enter if you're not sure.)", + choices=get_browser_picker_choices(), + default=DEFAULT_BROWSER, + ).execute() + site = inquirer.rawlist( + message="Which site should the tool auto-fill your project into? (Press Enter if you're not sure.)", + choices=get_site_picker_choices(), + default=DEFAULT_SITE, + ).execute() + + if site == TargetSites.DriveThruCards.name: + return browser, site, True, False + + auto_save = inquirer.rawlist( + message=( + "Automatically save this project to your account while the tool is running? " + "(Press Enter if you're not sure.)" + ), + choices=[{"name": "Yes", "value": True}, {"name": "No", "value": False}], + default=DEFAULT_AUTO_SAVE, + ).execute() + image_post_processing = inquirer.rawlist( + message=( + "Should the tool post-process your images to reduce upload times? By default, images will be " + "downscaled to 800 DPI. (Press Enter if you're not sure.)" + ), + choices=[{"name": "Yes", "value": True}, {"name": "No", "value": False}], + default=DEFAULT_IMAGE_POST_PROCESSING, + ).execute() + return browser, site, auto_save, image_post_processing + + +def check_tls_connection() -> None: + from urllib.request import urlopen + + with urlopen(TLS_CHECK_URL, timeout=30) as response: + if response.status != 200: + raise click.ClickException(f"TLS check returned HTTP {response.status}.") + + +def get_ghostscript_path(path: Optional[str] = None) -> Optional[str]: + from src.pdf_maker import get_ghostscript_path as resolve_path + + return resolve_path(path) + +def get_ghostscript_version(path: str) -> Optional[str]: + from src.pdf_maker import get_ghostscript_version as resolve_version -def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: + return resolve_version(path) + + +def wait_for_user_to_complete_order() -> None: + input( + f"If this software has brought you joy and you'd like to throw a few bucks my way,\n" + f"you can find my tip jar here: {bold('https://www.buymeacoffee.com/chilli.axe')} Thank you!\n\n" + f"Press {bold('Enter')} to close this window - your browser window will remain open.\n" + ) + + +def _install_ghostscript_windows() -> bool: + import hashlib + import tempfile + from urllib.request import Request, urlopen + + suffix = "w64.exe" if sys.maxsize > 2**32 else "w32.exe" + download_url, expected_digest = GHOSTSCRIPT_WINDOWS_INSTALLERS[suffix] + + logger.info(f"Downloading Ghostscript {GHOSTSCRIPT_VERSION} from Artifex...") + with tempfile.TemporaryDirectory() as directory: + installer_path = os.path.join(directory, os.path.basename(download_url)) + download = Request(download_url, headers={"User-Agent": "mpc-autofill"}) + with urlopen(download, timeout=60) as response, open(installer_path, "wb") as installer: + shutil.copyfileobj(response, installer) + with open(installer_path, "rb") as installer: + actual_digest = hashlib.file_digest(installer, "sha256").hexdigest() + if actual_digest != expected_digest: + raise RuntimeError("The downloaded Ghostscript installer failed checksum verification.") + + command = ( + "$process = Start-Process -FilePath $env:MPC_AUTOFILL_GS_INSTALLER " + "-Verb RunAs -Wait -PassThru; exit $process.ExitCode" + ) + environment = os.environ.copy() + environment["MPC_AUTOFILL_GS_INSTALLER"] = installer_path + return ( + subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command], + check=False, + env=environment, + ).returncode + == 0 + ) + + +def _install_ghostscript() -> bool: + """ + Attempt to install Ghostscript, logging failures and returning whether the installer succeeded. + """ + + if sys.platform.startswith("darwin"): + if shutil.which("brew") is None: + logger.info("Homebrew not found. Please install Homebrew, then re-run.") + return False + else: + logger.info("Installing Ghostscript via Homebrew...") + result = subprocess.run(["brew", "install", "ghostscript"], check=False) + if result.returncode != 0: + logger.warning("Ghostscript installation via Homebrew failed.") + return result.returncode == 0 + elif sys.platform.startswith("win"): + try: + if _install_ghostscript_windows(): + return True + except Exception as error: + logger.warning(f"Ghostscript installation failed: {error}") + logger.info(f"Please install Ghostscript from {GHOSTSCRIPT_DOWNLOAD_PAGE}.") + return False + else: + package_manager = next( + (candidate for candidate in ["apt", "dnf", "yum"] if shutil.which(candidate) is not None), None + ) + if shutil.which("sudo") is None: + logger.info("sudo not found. Please install Ghostscript with your package manager manually.") + return False + elif package_manager is None: + logger.info("No supported package manager found. Please install Ghostscript manually.") + return False + else: + logger.info(f"Installing Ghostscript via {package_manager}...") + result = subprocess.run(["sudo", package_manager, "install", "-y", "ghostscript"], check=False) + if result.returncode != 0: + logger.warning(f"Ghostscript installation via {package_manager} failed.") + return result.returncode == 0 + + +def ensure_ghostscript_available() -> str: """ - We only prompt users to specify some flags if the tool was executed with no command-line arguments. + Detect Ghostscript, offering to install it (with the user's explicit consent) until it's available. """ - return f"{prompt} (Press Enter if you're not sure.)" if len(sys.argv) == 1 else False + while True: + gs_path = get_ghostscript_path() + if gs_path: + version = get_ghostscript_version(gs_path) + if version: + logger.info(f"Ghostscript detected: {bold(version)} at {bold(gs_path)}") + else: + logger.info(f"Ghostscript detected at {bold(gs_path)}") + return gs_path + + logger.info("DriveThruCards export requires Ghostscript for PDF/X-1a compliance.") + if click.confirm("Is it okay if MPC Autofill tries to install Ghostscript now?", default=True): + if _install_ghostscript(): + continue + + logger.info( + "Please install Ghostscript, then return here to continue.\n" + "macOS: brew install ghostscript\n" + "Windows: https://ghostscript.com/releases/gsdnld.html\n" + "Linux: use your package manager (e.g., apt install ghostscript)." + ) + input("Press Enter to re-check for Ghostscript, or Ctrl+C to exit.") + + +def get_existing_pdf_paths(order_name: Optional[str]) -> list[str]: + from src.pdf_maker import get_export_directory + + export_directory = get_export_directory(order_name=order_name) + return sorted([path for path in glob(os.path.join(export_directory, "**", "*.pdf"), recursive=True)]) + + +def get_newest_mtime_in_directory(directory: str) -> Optional[float]: + if not os.path.isdir(directory): + return None + newest_mtime = None + for root, _, files in os.walk(directory): + for file_name in files: + file_path = os.path.join(root, file_name) + mtime = os.path.getmtime(file_path) + if newest_mtime is None or mtime > newest_mtime: + newest_mtime = mtime + return newest_mtime + + +def existing_pdfs_are_stale(existing_pdf_paths: list[str], cards_directory: str) -> bool: + if not existing_pdf_paths: + return False + newest_cards_mtime = get_newest_mtime_in_directory(cards_directory) + if newest_cards_mtime is None: + return False + newest_pdf_mtime = max(os.path.getmtime(path) for path in existing_pdf_paths) + return newest_cards_mtime > newest_pdf_mtime + + +def maybe_reuse_existing_pdfs( + order_name: Optional[str], + skip_pdf_if_exists: bool, + cards_directory: str, + require_pdfx: bool = False, +) -> Optional[list[str]]: + if not skip_pdf_if_exists: + return None + + existing_pdf_paths = get_existing_pdf_paths(order_name=order_name) + if not existing_pdf_paths: + return None + + # When a PDF/X-1a file is required, it's also the file whose freshness matters - + # a stale _pdfx.pdf must not be reused just because some other PDF is newer. + relevant_pdf_paths = existing_pdf_paths + if require_pdfx: + relevant_pdf_paths = [path for path in existing_pdf_paths if path.endswith("_pdfx.pdf")] + if not relevant_pdf_paths: + logger.info("Existing PDF files were found, but no PDF/X-1a output was found. Recreating PDF export.") + return None + + if existing_pdfs_are_stale(existing_pdf_paths=relevant_pdf_paths, cards_directory=cards_directory): + recreate_pdf = click.confirm( + "Existing PDF export found, but images in cards/ are newer. Recreate PDF now?", + default=True, + ) + if recreate_pdf: + return None + + logger.info("Skipping PDF generation because existing exported PDF files were found.") + return existing_pdf_paths + + +def download_images_for_orders( + orders: list[CardOrder], + post_processing_config: Optional[ImagePostProcessingConfig], +) -> None: + from concurrent.futures import ThreadPoolExecutor + + import enlighten + + from src.constants import THREADS + from src.exc import ImageDownloadError + + total_images = sum(len(order.fronts.cards_by_id) + len(order.backs.cards_by_id) for order in orders) + manager = enlighten.get_manager() + download_bar = manager.counter( + total=total_images, desc="Images Downloaded", position=1, autorefresh=True, leave=False + ) + try: + with ThreadPoolExecutor(max_workers=THREADS) as pool: + for order in orders: + logger.info(f"Downloading images for {bold(order.name or 'Unnamed Project')}...") + order.fronts.download_images(pool, download_bar, post_processing_config) + order.backs.download_images(pool, download_bar, post_processing_config) + finally: + download_bar.close(clear=True) + manager.stop() + failed_images = sorted({failed for order in orders for failed in order.get_failed_downloads()}) + if failed_images: + raise ImageDownloadError(failed_images) + logger.info("Finished downloading card images.") + + +def get_dtc_pdf_paths_for_order( + order: CardOrder, + skip_pdf_if_exists: bool, + working_directory: str, + resolved_icc_profile: Optional[str], + downscale_alg: str, +) -> list[str]: + from src.io import get_image_directory + from src.pdf_maker import PdfExporter, PdfXConversionConfig + from src.processing import ImagePostProcessingConfig + + pdf_paths = maybe_reuse_existing_pdfs( + order_name=order.name, + skip_pdf_if_exists=skip_pdf_if_exists, + cards_directory=get_image_directory(working_directory), + require_pdfx=True, + ) + if pdf_paths is not None: + return pdf_paths + + dtc_post_processing_config = ImagePostProcessingConfig( + max_dpi=300, + downscale_alg=ImageResizeMethods[downscale_alg], + output_format="JPEG", + convert_to_cmyk=False, + ) + exporter = PdfExporter( + order=order, + export_mode="drive_thru_cards", + pdfx_config=PdfXConversionConfig(icc_profile_path=resolved_icc_profile), + ) + return exporter.execute(post_processing_config=dtc_post_processing_config) @click.command(context_settings={"show_default": True}) @@ -52,9 +412,8 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: @click.option( "-b", "--browser", - prompt=prompt_if_no_arguments("Which web browser should the tool run on?"), - default=Browsers.chrome.name, - type=click.Choice(sorted([browser.name for browser in Browsers]), case_sensitive=False), + default=DEFAULT_BROWSER, + type=click.Choice(get_browser_picker_choices(), case_sensitive=False), help="The web browser to run the tool on.", ) @click.option( @@ -66,17 +425,34 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: "to locate it at startup. This is most likely to occur if using the Brave browser." ), ) +@click.option( + "--browser-profile-path", + default=None, + help=( + "Optional Chromium user-data directory for reusing existing profiles, cookies, and password managers " + "when targeting DriveThruCards. Example on macOS: ~/Library/Application Support/Google/Chrome" + ), +) +@click.option( + "--browser-profile-name", + default="Default", + help="Profile directory name inside --browser-profile-path (e.g. Default or 'Profile 1').", +) +@click.option( + "--skip-dtc-instructions", + default=False, + help="Open DriveThruCards immediately without showing the browser instruction page.", + is_flag=True, +) @click.option( "--site", - prompt=prompt_if_no_arguments("Which site should the tool auto-fill your project into?"), - default=TargetSites.MakePlayingCards.name, - type=click.Choice(sorted([site.name for site in TargetSites]), case_sensitive=False), + default=DEFAULT_SITE, + type=click.Choice(get_site_picker_choices(), case_sensitive=False), help="The card printing site into which your order should be auto-filled.", ) @click.option( "--auto-save/--no-auto-save", - prompt=prompt_if_no_arguments("Automatically save this project to your account while the tool is running?"), - default=True, + default=DEFAULT_AUTO_SAVE, help=( "If this flag is passed, the tool will automatically save your project to your account after " "processing each batch of cards." @@ -95,6 +471,18 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: help="Create a PDF export of the card images instead of creating a project with a printing site.", is_flag=True, ) +@click.option( + "--download-images-only", + default=False, + help="Download card images to cards/ and exit (skip PDF creation and browser automation).", + is_flag=True, +) +@click.option( + "--skip-pdf-if-exists", + default=False, + help="Reuse existing export PDFs when present; prompts to recreate if cards/ has newer files.", + is_flag=True, +) @click.option( "--allowsleep/--disallow-sleep", default=False, @@ -103,11 +491,7 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: ) @click.option( "--image-post-processing/--no-image-post-processing", - default=True, - prompt=prompt_if_no_arguments( - "Should the tool post-process your images to reduce upload times? " - "By default, images will be downscaled to 800 DPI." - ), + default=DEFAULT_IMAGE_POST_PROCESSING, help="Post-process images to reduce file upload time.", is_flag=True, ) @@ -127,6 +511,14 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: "\nhttps://pillow.readthedocs.io/en/latest/handbook/concepts.html#filters-comparison-table" ), ) +@click.option( + "--dtc-icc-profile", + default=None, + help=( + "Optional ICC profile path for DriveThruCards PDF/X conversion " + "(by default, an installed US Web Coated (SWOP) profile is located or downloaded from Adobe)." + ), +) @click.option( "--combine-orders/--no-combine-orders", default=True, @@ -148,7 +540,7 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: logging.getLevelName(logging.NOTSET), ] ), - help="Controls the level of logs written to standard output.", + help="Global CLI output verbosity. Use DEBUG to show detailed Selenium step-by-step logs.", ) @click.option( "--write-debug-logs", @@ -156,6 +548,7 @@ def prompt_if_no_arguments(prompt: str) -> Union[str, bool]: help="If True, debug logs about the tool's actions will be logged to autofill_log.txt in the tool's directory.", is_flag=True, ) +@click.option("--check-tls", is_flag=True, hidden=True) # @click.option( # TODO: finish implementing jpeg conversion # "--convert-to-jpeg", # default=True, @@ -169,17 +562,44 @@ def main( browser: str, directory: Optional[str], binary_location: Optional[str], + browser_profile_path: Optional[str], + browser_profile_name: str, + skip_dtc_instructions: bool, site: str, exportpdf: bool, + download_images_only: bool, + skip_pdf_if_exists: bool, allowsleep: bool, image_post_processing: bool, max_dpi: int, downscale_alg: str, + dtc_icc_profile: Optional[str], combine_orders: bool, log_level: str, write_debug_logs: bool, + check_tls: bool, # convert_to_jpeg: bool, ) -> None: + if check_tls: + check_tls_connection() + click.echo("TLS check succeeded.") + return + + if should_run_interactive_onboarding(): + browser, site, auto_save, image_post_processing = run_interactive_onboarding() + + from wakepy import keepawake + + from src.exc import ImageDownloadError, ValidationException + from src.io import ( + DEFAULT_WORKING_DIRECTORY, + create_image_directory_if_not_exists, + get_image_directory, + ) + from src.logging import configure_loggers + from src.order import CardOrder, aggregate_and_split_orders + from src.processing import ImagePostProcessingConfig + working_directory: str = DEFAULT_WORKING_DIRECTORY if directory: if not os.path.isdir(directory): @@ -191,9 +611,14 @@ def main( os.chdir(working_directory) create_image_directory_if_not_exists(working_directory=working_directory) - if binary_location and not os.path.isdir(binary_location): + if binary_location and not os.path.isfile(binary_location): raise Exception( - f"Binary location was specified but is not a directory (or it doesn't exist): {bold(binary_location)}" + f"Binary location was specified but is not a file (or it doesn't exist): {bold(binary_location)}" + ) + if browser_profile_path and not os.path.isdir(browser_profile_path): + raise Exception( + "Browser profile path was specified but is not a directory (or it doesn't exist): " + f"{bold(browser_profile_path)}" ) configure_loggers( @@ -201,6 +626,17 @@ def main( log_debug_to_file=write_debug_logs, stdout_log_level=logging.getLevelName(log_level), ) + if TargetSites[site] == TargetSites.DriveThruCards: + explicit = click.get_current_context().get_parameter_source + if not auto_save and explicit("auto_save") == ParameterSource.COMMANDLINE: + logger.info("Ignoring --no-auto-save: DriveThruCards orders are always saved to your account.") + if image_post_processing and explicit("image_post_processing") == ParameterSource.COMMANDLINE: + logger.info( + "Ignoring --image-post-processing: DriveThruCards images are already downscaled once " + "during PDF creation." + ) + auto_save = True + image_post_processing = False try: with keepawake(keep_screen_awake=True) if not allowsleep else nullcontext(): logger.info("MPC Autofill desktop tool has successfully initialised!") @@ -208,38 +644,115 @@ def main( logger.info("System sleep is being prevented during this execution.") if image_post_processing: logger.info("Images are being post-processed during this execution.") + target_site = TargetSites[site] post_processing_config = ( ImagePostProcessingConfig(max_dpi=max_dpi, downscale_alg=ImageResizeMethods[downscale_alg]) if image_post_processing else None ) - if exportpdf: - PdfExporter(order=CardOrder.from_xmls_in_folder(working_directory=working_directory)[0]).execute( - post_processing_config=post_processing_config - ) + if download_images_only: + orders = CardOrder.from_xmls_in_folder(working_directory=working_directory) + download_images_for_orders(orders=orders, post_processing_config=post_processing_config) + return + if target_site == TargetSites.DriveThruCards: + from src.driver import AutofillDriver + from src.icc import find_or_download_dtc_icc_profile + from src.web_server import WebServer + + ensure_ghostscript_available() + if dtc_icc_profile and not os.path.isfile(dtc_icc_profile): + raise Exception( + f"DriveThruCards ICC profile path does not exist or is not a file: {bold(dtc_icc_profile)}" + ) + resolved_icc_profile = dtc_icc_profile or find_or_download_dtc_icc_profile() + if resolved_icc_profile is None: + logger.warning( + "No ICC profile is available - Ghostscript's default CMYK conversion will be used " + "instead. Print colours may differ from previous orders." + ) + else: + logger.info(f"DriveThruCards ICC profile: {bold(resolved_icc_profile)}") + orders = CardOrder.from_xmls_in_folder(working_directory=working_directory) + dtc_driver: Optional[AutofillDriver] = None + dtc_web_server: Optional[WebServer] = None + for i, order in enumerate(orders, start=1): + pdf_paths = get_dtc_pdf_paths_for_order( + order=order, + skip_pdf_if_exists=skip_pdf_if_exists, + working_directory=working_directory, + resolved_icc_profile=resolved_icc_profile, + downscale_alg=downscale_alg, + ) + if exportpdf: + continue + # Only use the Ghostscript PDF/X-1a output - no fallback + dtc_pdf_path = next((path for path in reversed(pdf_paths) if path.endswith("_pdfx.pdf")), None) + if dtc_pdf_path is None: + raise Exception( + "Ghostscript PDF/X-1a conversion failed. Cannot proceed with DriveThruCards upload.\n" + "Please fix the Ghostscript conversion issue and try again." + ) + if dtc_driver is None: + starting_url = target_site.value.starting_url + if not skip_dtc_instructions: + logger.info( + "DriveThruCards setup will open in your browser. " "Follow the instructions there." + ) + dtc_web_server = WebServer(DTC_POST_LAUNCH_HTML_FILENAME) + starting_url = dtc_web_server.server_url() + dtc_driver = AutofillDriver( + browser=Browsers[browser], + target_site=target_site, + binary_location=binary_location, + browser_profile_path=browser_profile_path, + browser_profile_name=browser_profile_name if browser_profile_path else None, + starting_url=starting_url, + ) + dtc_driver.execute_drive_thru_cards_order(order=order, pdf_path=dtc_pdf_path) + if i < len(orders): + input(f"Press {bold('Enter')} to continue with the next DriveThruCards order.\n") + if dtc_driver is not None: + wait_for_user_to_complete_order() + elif exportpdf: + from src.pdf_maker import PdfExporter + + order = CardOrder.from_xmls_in_folder(working_directory=working_directory)[0] + if ( + maybe_reuse_existing_pdfs( + order_name=order.name, + skip_pdf_if_exists=skip_pdf_if_exists, + cards_directory=get_image_directory(working_directory), + ) + is None + ): + PdfExporter(order=order).execute(post_processing_config=post_processing_config) else: - target_site = TargetSites[site] + from src.driver import AutofillDriver + from src.web_server import WebServer + card_orders = aggregate_and_split_orders( orders=CardOrder.from_xmls_in_folder(working_directory=working_directory), target_site=target_site, combine_orders=combine_orders, ) web_server = WebServer() - AutofillDriver( + autofill_driver = AutofillDriver( browser=Browsers[browser], target_site=target_site, binary_location=binary_location, + browser_profile_path=browser_profile_path, + browser_profile_name=browser_profile_name if browser_profile_path else None, starting_url=web_server.server_url(), - ).execute_orders( + ) + autofill_driver.execute_orders( orders=card_orders, auto_save_threshold=auto_save_threshold if auto_save else None, post_processing_config=post_processing_config, ) - input( - f"If this software has brought you joy and you'd like to throw a few bucks my way,\n" - f"you can find my tip jar here: {bold('https://www.buymeacoffee.com/chilli.axe')} Thank you!\n\n" - f"Press {bold('Enter')} to close this window - your browser window will remain open.\n" - ) + wait_for_user_to_complete_order() + except ImageDownloadError as e: + logger.error(str(e)) + input("Press Enter to exit.") except ValidationException as e: input(f"There was a problem with your order file:\n\n{bold(e)}\n\nPress Enter to exit.") sys.exit(0) @@ -250,10 +763,4 @@ def main( if __name__ == "__main__": - click.echo( - "▙▗▌▛▀▖▞▀▖ ▞▀▖ ▐ ▗▀▖▗▜▜ \n" - "▌▘▌▙▄▘▌ ▙▄▌▌ ▌▜▀ ▞▀▖▐ ▄▐▐ \n" - "▌ ▌▌ ▌ ▖ ▌ ▌▌ ▌▐ ▖▌ ▌▜▀ ▐▐▐ \n" - "▘ ▘▘ ▝▀ ▘ ▘▝▀▘ ▀ ▝▀ ▐ ▀▘▘▘\n" - ) main() diff --git a/desktop-tool/docs/Desktop-Tool.wiki.addendum.md b/desktop-tool/docs/Desktop-Tool.wiki.addendum.md new file mode 100644 index 000000000..4ebc89492 --- /dev/null +++ b/desktop-tool/docs/Desktop-Tool.wiki.addendum.md @@ -0,0 +1,127 @@ +# Desktop Tool Wiki Addendum + +Target page: + +These updates describe the DriveThruCards workflow in the `drivethrucards` branch. They can be copied into the wiki when PR [#367](https://github.com/chilli-axe/mpc-autofill/pull/367) is merged. + +## Wiki Home + +On the wiki Home page: + +- Describe the desktop tool as preparing orders for supported card printers, including MakePlayingCards and DriveThruCards. +- Replace the Google Drive CI warning with: + +> - GitHub Actions runs the backend and desktop tool test suites. Tests that need Google Drive credentials use the `GOOGLE_DRIVE_API_KEY` repository secret. +> - Pull requests from forks cannot access secrets stored in this repository. The desktop tool workflow still runs without the secret and skips only the credential-backed tests. +> - To run the complete desktop tool suite in your fork, add `GOOGLE_DRIVE_API_KEY` to the fork and run the workflow there. The secret's value is the full Google service-account JSON document, not a plain API key. + +## GitHub Repo Configuration + +Replace the `GOOGLE_DRIVE_API_KEY` description with: + +> The full Google service-account JSON document, despite the historical name. Used by GitHub Actions for credential-backed backend and desktop tool tests, and built into the desktop tool binary. Pull requests from forks cannot read the upstream secret. The desktop workflow skips credential-backed tests when it is unavailable. Fork owners can add their own copy to run the complete suite in their fork. + +## Overview + +Replace the opening with: + +> This tool ingests XML files generated with this project's web frontend, and: +> +> - Downloads the images in your order from Google Drive (into the directory `/cards` from the executable's location), +> - Uses Selenium (browser automation) for Chromium browsers to prepare an order with a supported card printer. +> +> For MakePlayingCards and PrinterStudio, the tool fills the site's card editor one image at a time. For DriveThruCards, it creates a print-ready PDF/X-1a file and uploads it through the publisher tools. +> +> Once the autofilling process completes, you can review and pay for your order. MakePlayingCards and PrinterStudio projects can also be saved to your account for later. +> +> **Note**: Automated Chromium browsers do not support signing in with Google accounts for security reasons. Create an account with the printing site directly and sign in with it. + +## Running the Tool + +Add this item after the current release download: + +> - To use DriveThruCards before PR [#367](https://github.com/chilli-axe/mpc-autofill/pull/367) is merged, download the [latest DTC release](https://github.com/bwsinger/mpc-autofill/releases/latest). It includes Windows, Linux, Apple silicon Mac, and Intel Mac builds. + +## DriveThruCards Automation + +Add this section after "MakePlayingCards Automation": + +> ### DriveThruCards Automation +> +> - The tool downloads the images in your order and creates a PDF for DriveThruCards' Premium Euro Poker size. +> - It converts the PDF to PDF/X-1a:2001 with [Ghostscript](https://ghostscript.com/) before opening the DriveThruCards publisher tools. +> - If your DriveThruCards account does not have publisher access, the tool completes the non-exclusive publisher setup. +> - You sign in yourself. The tool then creates the product, uploads the PDF, and opens the checkout page for your review. It never submits payment. +> - Multiple XML files use the same browser session, with a prompt between orders. +> +> DriveThruCards requires Ghostscript. When it is missing, the tool asks before trying to install it with Homebrew, winget, apt, dnf, or yum. If you decline, install Ghostscript yourself and return to the prompt. +> +> For more consistent print colours, the tool looks for Adobe's US Web Coated (SWOP) ICC profile in the usual system folders and in `~/.mpc-autofill/`. If it cannot find the profile, it can download Adobe's end-user profile bundle, verify its SHA-256 checksum, and cache the profile. You can decline and continue with Ghostscript's default colour conversion. + +## Specifying a Site to Autofill Into (`--site`) + +Add DriveThruCards to the list of supported sites: + +> - [DriveThruCards](https://www.drivethrucards.com) +> +> DriveThruCards uses Chrome or Brave. If you select another browser, the tool falls back to Chrome and tells you before continuing. + +## PDF and Download Arguments + +Replace "Exporting to PDF" with the following sections: + +> ### Exporting to PDF (`--exportpdf`) +> +> Use `--exportpdf` to create PDF files without opening a browser or starting an order. +> +> With `--site DriveThruCards`, this creates the same PDF/X-1a file used by the automated DTC workflow, then exits. Ghostscript is still required. +> +> ### Downloading Images Only (`--download-images-only`) +> +> Use `--download-images-only` to download the card images into `cards/` and exit without creating a PDF or opening a browser. +> +> ### Reusing PDF Exports (`--skip-pdf-if-exists`) +> +> Use `--skip-pdf-if-exists` to reuse existing PDF exports. If an image in `cards/` is newer than the PDF, the tool asks whether to rebuild it. DriveThruCards orders only reuse an existing `_pdfx.pdf` file. + +## DriveThruCards Arguments + +Add these sections under "Command-Line Arguments": + +> ### Reusing a Chromium Profile (`--browser-profile-path`, `--browser-profile-name`) +> +> Use `--browser-profile-path` to open DriveThruCards with an existing Chromium user data directory. This makes saved cookies and password managers available in the automated browser. Use `--browser-profile-name` to select a profile within that directory. The default profile name is `Default`. +> +> Close any regular browser windows using that profile before starting the tool. Chromium does not allow two browser processes to use the same profile at once. +> +> ### Skipping DriveThruCards Instructions (`--skip-dtc-instructions`) +> +> DriveThruCards normally opens a short instruction page before the sign-in page. Use `--skip-dtc-instructions` to open DriveThruCards immediately. +> +> ### Specifying a DriveThruCards ICC Profile (`--dtc-icc-profile`) +> +> Use `--dtc-icc-profile` to provide your own `.icc` profile for DriveThruCards PDF/X conversion. If you omit it, the tool looks for the US Web Coated (SWOP) profile or offers to download it from Adobe. + +## Running the Test Suite + +Replace the existing commands with: + +> From the `desktop-tool` directory, run: +> +> ```shell +> pytest . +> ``` +> +> On Linux, run the suite in a D-Bus session: +> +> ```shell +> dbus-run-session -- pytest . +> ``` +> +> Tests that need Google Drive credentials skip themselves when `client_secrets.json` is not available. + +## Building the Project + +Replace the Nuitka build command's hyphen with a colon, then add: + +> The dispatchable desktop build workflow produces one-file builds for Windows, Linux, Apple silicon Macs, and Intel Macs. Each build runs `--check-tls` and checks that `--help` lists both MakePlayingCards and DriveThruCards before GitHub stores the artifact. diff --git a/desktop-tool/dtc-post-launch.html b/desktop-tool/dtc-post-launch.html new file mode 100644 index 000000000..6ac23a183 --- /dev/null +++ b/desktop-tool/dtc-post-launch.html @@ -0,0 +1,134 @@ + + + + + + MPC Autofill — DriveThruCards + + + +
+

MPC Autofill · DriveThruCards

+

Sign in on the next page

+
    +
  1. + 1 + This page — you are here +

    Turn off ad blockers

    +

    They can break the DriveThruCards login. Do this before continuing.

    +
  2. + +
  3. + 3 + Automatic +

    MPC Autofill resumes

    +

    Once you're logged in, the tool detects it and continues on its own.

    +
  4. +
+ Continue to DriveThruCards → +

Skip this page next time with --skip-dtc-instructions

+
+ + diff --git a/desktop-tool/requirements.txt b/desktop-tool/requirements.txt index ab6075257..a51b18961 100644 --- a/desktop-tool/requirements.txt +++ b/desktop-tool/requirements.txt @@ -1,4 +1,5 @@ attrs~=25.3.0 +certifi click==8.1.8 colorama~=0.4.6 coverage~=7.2.7 @@ -19,4 +20,6 @@ ratelimit~=2.2.1 requests~=2.32.5 sanitize-filename~=1.2.0 selenium~=4.35 +setuptools # Required for undetected-chromedriver on Python 3.13+ (distutils removed) +undetected-chromedriver~=3.5.5 wakepy==0.6.0 diff --git a/desktop-tool/src/constants.py b/desktop-tool/src/constants.py index 663212770..d007b82f0 100644 --- a/desktop-tool/src/constants.py +++ b/desktop-tool/src/constants.py @@ -152,6 +152,29 @@ def accept_settings_url(self) -> str: return self.format_url(self.accept_settings_url_route) +@attr.s +class DriveThruCardsSelectors: + product_url: str = attr.ib() + # Opening the login modal lets the user choose sign-in or account creation. + login_button_selector: str = attr.ib(default="button[data-cy='login']") + authenticated_indicator_selector: str = attr.ib( + default="[data-cy='accountMenu'], [aria-label='Log Out'], " "a[href*='logoff'], a[href*='logout']" + ) + # This Publish link only appears after publisher access is enabled. + publisher_ready_selector: str = attr.ib(default="a[href*='pub_tools.php']") + + +@attr.s +class DriveThruCardsSite: + selectors: DriveThruCardsSelectors = attr.ib( + default=attr.Factory(lambda: DriveThruCardsSelectors(product_url="https://www.drivethrucards.com")) + ) + + @property + def starting_url(self) -> str: + return self.selectors.product_url + + class TargetSites(Enum): MakePlayingCards = TargetSite( base_url="https://www.makeplayingcards.com", starting_url_route="design/custom-blank-card.html" @@ -214,6 +237,7 @@ class TargetSites(Enum): Cardstocks.P10: "Plastique (100%)", }, ) + DriveThruCards = DriveThruCardsSite() DPI_HEIGHT_RATIO = 300 / 1110 # TODO: share this between desktop tool and backend @@ -223,3 +247,4 @@ class TargetSites(Enum): THREADS = 5 # shared between CardImageCollections POST_LAUNCH_HTML_FILENAME = "post-launch.html" +DTC_POST_LAUNCH_HTML_FILENAME = "dtc-post-launch.html" diff --git a/desktop-tool/src/driver.py b/desktop-tool/src/driver.py index f29e26e03..a5c3ce090 100644 --- a/desktop-tool/src/driver.py +++ b/desktop-tool/src/driver.py @@ -1,4 +1,6 @@ import datetime as dt +import os +import re import textwrap import time from concurrent.futures import ThreadPoolExecutor @@ -11,10 +13,14 @@ from InquirerPy import inquirer from selenium.common import exceptions as sl_exc from selenium.common.exceptions import NoAlertPresentException, NoSuchElementException +from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.expected_conditions import ( + element_to_be_clickable, invisibility_of_element, + presence_of_element_located, text_to_be_present_in_element, visibility_of_element_located, ) @@ -29,7 +35,7 @@ States, TargetSites, ) -from src.exc import InvalidStateException +from src.exc import ImageDownloadError, InvalidStateException from src.formatting import bold from src.logging import logger from src.order import CardImage, CardImageCollection, CardOrder @@ -40,6 +46,10 @@ ignore_javascript_errors, log_hours_minutes_seconds_elapsed, ) +from src.webdrivers import ( + get_default_brave_binary_location, + get_undetected_chrome_driver, +) @attr.s @@ -48,6 +58,8 @@ class AutofillDriver: driver: WebDriver = attr.ib(default=None) # delay initialisation until XML is selected and parsed browser: Browsers = attr.ib(default=Browsers.chrome) binary_location: Optional[str] = attr.ib(default=None) # path to browser executable + browser_profile_path: Optional[str] = attr.ib(default=None) # user data dir for Chromium browsers + browser_profile_name: Optional[str] = attr.ib(default=None) # profile directory name, e.g. "Profile 1" target_site: TargetSites = attr.ib(default=TargetSites.MakePlayingCards) headless: bool = attr.ib(default=False) starting_url: str = attr.ib(default="data:") @@ -63,24 +75,63 @@ class AutofillDriver: # region initialisation - def initialise_driver(self) -> None: + @staticmethod + def _quit_driver_quietly(driver: Optional[WebDriver]) -> None: + if driver is None: + return try: - driver = self.browser.value(headless=self.headless, binary_location=self.binary_location) # type: ignore # TODO - driver.set_window_size(1200, 900) - driver.implicitly_wait(5) - driver.get(self.starting_url) - WebDriverWait(driver, 10).until(visibility_of_element_located((By.TAG_NAME, "body"))) - logger.info( - f"Successfully initialised {bold(self.browser.name)} driver " - f"targeting {bold(self.target_site.name)}.\n" - ) - except (AttributeError, ValueError, sl_exc.WebDriverException) as e: - raise Exception( - f"An error occurred while attempting to configure the webdriver for your specified browser. " - f"Please make sure you have installed the browser & that it is up to date:\n\n{bold(e)}" + driver.quit() + except Exception: + pass + + def create_driver(self) -> WebDriver: + if self.target_site == TargetSites.DriveThruCards: + # DriveThruCards' bot detection (Cloudflare) blocks standard Selenium, so it needs + # undetected-chromedriver. Other sites keep their standard drivers untouched. + binary_location = self.binary_location + if self.browser == Browsers.brave and binary_location is None: + binary_location = get_default_brave_binary_location() + elif self.browser not in (Browsers.chrome, Browsers.brave): + logger.info( + f"DriveThruCards automation requires a Chromium browser - " + f"using {bold('Chrome')} instead of {bold(self.browser.name)}." + ) + return get_undetected_chrome_driver( + headless=self.headless, + binary_location=binary_location, + user_data_dir=self.browser_profile_path, + profile_directory=self.browser_profile_name, ) + return self.browser.value(headless=self.headless, binary_location=self.binary_location) # type: ignore[operator] - self.driver = driver + def initialise_driver(self) -> None: + max_attempts = 3 + for attempt in range(1, max_attempts + 1): + driver = None + try: + driver = self.create_driver() + driver.set_window_size(1200, 900) + driver.implicitly_wait(5) + driver.get(self.starting_url) + WebDriverWait(driver, 10).until(visibility_of_element_located((By.TAG_NAME, "body"))) + logger.info( + f"Successfully initialised {bold(self.browser.name)} driver " + f"targeting {bold(self.target_site.name)}.\n" + ) + self.driver = driver + return + except (AttributeError, ValueError, sl_exc.WebDriverException) as e: + self._quit_driver_quietly(driver) + if attempt < max_attempts: + logger.warning( + f"Webdriver initialisation attempt {attempt}/{max_attempts} failed; retrying.\n" f"{bold(e)}" + ) + time.sleep(1) + continue + raise Exception( + f"An error occurred while attempting to configure the webdriver for your specified browser. " + f"Please make sure you have installed the browser & that it is up to date:\n\n{bold(e)}" + ) def initialise_bars(self) -> None: # set the total for upload/download bars to 0 here, then change the total according to each order @@ -89,12 +140,16 @@ def initialise_bars(self) -> None: self.status_bar = self.manager.status_bar( status_format=status_format, state=bold(self.state), action=bold("N/A"), position=1, autorefresh=True ) + self.status_bar.refresh() + if self.target_site == TargetSites.DriveThruCards: + # DriveThruCards uploads a single PDF rather than individual images, so the per-image + # upload/download and project counters would only ever show 0/0 - don't create them. + return self.order_progress_bar = self.manager.counter( total=0, desc="Projects Auto-Filled", position=2, autorefresh=True ) self.download_bar = self.manager.counter(total=0, desc="Images Downloaded ", position=3, autorefresh=True) self.upload_bar = self.manager.counter(total=0, desc="Images Uploaded ", position=4, autorefresh=True) - self.status_bar.refresh() self.order_progress_bar.refresh() self.download_bar.refresh() self.upload_bar.refresh() @@ -202,6 +257,18 @@ def wait(self) -> bool: logger.debug(e) return False + @contextmanager + def no_implicit_wait(self) -> Generator[None, None, None]: + """ + Temporarily disable the driver's implicit wait so absent-element checks poll fast. + """ + + self.driver.implicitly_wait(0) + try: + yield + finally: + self.driver.implicitly_wait(5) + def set_state(self, state: str, action: Optional[str] = None) -> None: self.state = state self.action = action @@ -323,6 +390,665 @@ def handle_alert(self) -> None: # endregion + # region DriveThruCards + + def _try_click_turnstile_checkbox(self) -> bool: + """ + Attempt to find and click the Cloudflare Turnstile checkbox. + Returns True if checkbox was found and clicked, False otherwise. + """ + try: + with self.no_implicit_wait(): + # Turnstile is rendered in an iframe - find it by common attributes + iframe_selectors = [ + "iframe[src*='challenges.cloudflare.com']", + "iframe[title*='cloudflare']", + "iframe[title*='Cloudflare']", + "iframe[id*='cf-']", + ] + + iframe = None + for selector in iframe_selectors: + iframes = self.driver.find_elements(By.CSS_SELECTOR, selector) + if iframes: + iframe = iframes[0] + break + + if not iframe: + return False + + # Switch to iframe context + self.driver.switch_to.frame(iframe) + + try: + # Look for the checkbox input or clickable verification element + checkbox_selectors = [ + "input[type='checkbox']", + ".ctp-checkbox-label", + "#challenge-stage", + "[data-testid='challenge-input']", + ] + + for selector in checkbox_selectors: + elements = self.driver.find_elements(By.CSS_SELECTOR, selector) + for element in elements: + if element.is_displayed(): + element.click() + logger.debug("Clicked Turnstile checkbox") + return True + finally: + # Always switch back to main content + self.driver.switch_to.default_content() + + return False + except Exception as e: + logger.debug(f"Error clicking Turnstile checkbox: {e}") + # Ensure we're back in main content even on error + try: + self.driver.switch_to.default_content() + except Exception: + pass + return False + + def _is_cloudflare_challenge_active(self) -> bool: + """Check if a Cloudflare challenge page is currently displayed.""" + try: + if "just a moment" in self.driver.title.lower(): + return True + # Also check for challenge body text + with self.no_implicit_wait(): + body_text = self.driver.find_element(By.TAG_NAME, "body").text.lower() + return "verifying you are human" in body_text or "checking your browser" in body_text + except Exception: + return False + + def _is_site_loaded(self) -> bool: + """Check if the actual site content has loaded (past Cloudflare).""" + selectors = self.target_site.value.selectors + try: + if "just a moment" in self.driver.title.lower(): + return False + # Check for logged-out, basic-account, or publisher navigation. + with self.no_implicit_wait(): + login_btns = self.driver.find_elements(By.CSS_SELECTOR, selectors.login_button_selector) + authenticated = self.driver.find_elements(By.CSS_SELECTOR, selectors.authenticated_indicator_selector) + publisher_ready = self.driver.find_elements(By.CSS_SELECTOR, selectors.publisher_ready_selector) + return bool(login_btns or authenticated or publisher_ready) + except Exception: + return False + + def wait_for_cloudflare_challenge(self, timeout_seconds: int = 300) -> None: + """ + Wait for the Cloudflare challenge to be completed by waiting for site content to appear. + Uses aggressive polling and attempts to auto-click the Turnstile checkbox. + Waits for either the login button or Publisher Tools link (if already logged in). + """ + self.set_state(States.defining_order, "Waiting for site to load") + logger.info("Waiting for DriveThruCards to load...") + + poll_interval = 0.5 # Check every 500ms for responsive detection + turnstile_click_interval = 3.0 # Try clicking Turnstile every 3 seconds + last_turnstile_attempt = 0.0 + challenge_detected = False + start_time = time.time() + + while time.time() - start_time < timeout_seconds: + # Check if site has loaded successfully + if self._is_site_loaded(): + logger.info("Site loaded successfully!") + return + + # Check if we're on a Cloudflare challenge + if self._is_cloudflare_challenge_active(): + if not challenge_detected: + challenge_detected = True + logger.info("Cloudflare challenge detected. Attempting automatic handling...") + + # Periodically try to click the Turnstile checkbox + current_time = time.time() + if current_time - last_turnstile_attempt >= turnstile_click_interval: + last_turnstile_attempt = current_time + if self._try_click_turnstile_checkbox(): + logger.debug("Turnstile click attempted, waiting for verification...") + + time.sleep(poll_interval) + + # Timeout reached + raise TimeoutError(f"DriveThruCards did not finish loading after {timeout_seconds} seconds.") + + def is_dtc_publisher_ready(self) -> bool: + """Return whether the publisher-only Publish navigation link is visible.""" + selectors = self.target_site.value.selectors + try: + with self.no_implicit_wait(): + return any( + element.is_displayed() + for element in self.driver.find_elements(By.CSS_SELECTOR, selectors.publisher_ready_selector) + ) + except Exception as exc: + logger.debug(f"Error checking publisher status: {exc}") + return False + + def is_dtc_user_authenticated(self) -> bool: + """Check for a basic signed-in account independently of publisher access.""" + selectors = self.target_site.value.selectors + try: + with self.no_implicit_wait(): + authenticated_elements = self.driver.find_elements( + By.CSS_SELECTOR, selectors.authenticated_indicator_selector + ) + if any(element.is_displayed() for element in authenticated_elements): + return True + + logout_elements = self.driver.find_elements( + By.XPATH, + "//*[self::a or self::button][normalize-space()='Log Off' or normalize-space()='Log Out']", + ) + if any(element.is_displayed() for element in logout_elements): + return True + + # The Publish link also proves the user is signed in, even if the + # newer account-navigation selectors change. + return any( + element.is_displayed() + for element in self.driver.find_elements(By.CSS_SELECTOR, selectors.publisher_ready_selector) + ) + except Exception as exc: + logger.debug(f"Error checking auth status: {exc}") + return False + + def click_element_with_retry(self, element: Any) -> bool: + """ + Attempt to click an element using multiple strategies. + Returns True if click succeeded, False otherwise. + """ + # Strategy 1: Scroll into view and use native click + try: + self.driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", element) + element.click() + return True + except Exception as e: + logger.debug(f"Native click failed: {e}") + + # Strategy 2: JavaScript click (bypasses overlays and visibility issues) + try: + self.driver.execute_script("arguments[0].click();", element) + return True + except Exception as e: + logger.debug(f"JavaScript click failed: {e}") + + return False + + def click_element_polling(self, by: By, selector: str, timeout: int = 30) -> bool: + """ + Aggressively poll for an element and click it as soon as it's available. + No fixed waits - keeps trying until success or timeout. + """ + start = time.time() + with self.no_implicit_wait(): + while time.time() - start < timeout: + try: + elements = self.driver.find_elements(by, selector) + for el in elements: + if el.is_displayed() and self.click_element_with_retry(el): + return True + except Exception: + pass + time.sleep(0.1) # Small delay to avoid CPU spinning + return False + + def _click_dtc_login_button(self) -> bool: + """Click the DriveThruCards login button to open the login modal.""" + selectors = self.target_site.value.selectors + return self.click_element_polling(By.CSS_SELECTOR, selectors.login_button_selector, timeout=15) + + def authenticate_dtc(self) -> bool: + """ + Handle DriveThruCards login flow. + """ + if self.is_dtc_user_authenticated(): + logger.info("Already logged in to DriveThruCards.") + return True + + self.set_state(States.defining_order, "Awaiting DriveThruCards login") + + logger.info("Please log in to your DriveThruCards account.") + + if not self._click_dtc_login_button(): + logger.info( + "Could not find or click login button automatically.\n" "Please click the login button manually." + ) + + go_to_login_xpath = ( + "//*[@data-cy='authModalBody']" + "//*[self::a or self::button][contains(normalize-space(), 'Go to Log In') " + "or contains(normalize-space(), 'Go to Log in')]" + ) + if not self.click_element_polling(By.XPATH, go_to_login_xpath, timeout=15): + logger.info("Please click 'Go to Log In' in the browser.") + + logger.info("Complete sign-in or account creation in the browser. " "The tool will continue automatically.") + + # Wait for user to complete login (timeout after 5 minutes) + timeout_seconds = 300 + start_time = time.time() + while time.time() - start_time < timeout_seconds: + time.sleep(1) + if self.is_dtc_user_authenticated(): + logger.info("Successfully logged in to DriveThruCards!") + return True + + logger.warning(f"Login timeout after {timeout_seconds}s. " "Please ensure you're logged in before continuing.") + return False + + def open_dtc_starting_page(self) -> None: + """Wait for the local instruction-page button, or open DriveThruCards directly.""" + if self.starting_url.startswith("http://localhost:"): + self.set_state(States.defining_order, "Awaiting browser instructions") + try: + WebDriverWait(self.driver, 300, poll_frequency=0.2).until( + lambda driver: "drivethrucards.com" in driver.current_url + ) + except sl_exc.TimeoutException as exc: + raise TimeoutError("The DriveThruCards instruction page was not continued within 5 minutes.") from exc + return + + if "drivethrucards.com" not in self.driver.current_url: + self.driver.get(self.target_site.value.starting_url) + + def ensure_dtc_publisher_account(self) -> None: + """Create publisher permissions when the authoritative Publish link is absent.""" + if self.is_dtc_publisher_ready(): + logger.info("DriveThruCards publisher account is ready.") + return + + self.set_state(States.defining_order, "Setting up publisher account") + self.driver.get("https://www.drivethrucards.com/joinchoice.php") + + non_exclusive_xpath = ( + "//*[self::a or self::button or self::input]" + "[contains(normalize-space(.), 'Create My Non-Exclusive Publisher Account') " + "or contains(@value, 'Create My Non-Exclusive Publisher Account')]" + ) + if not self.click_element_polling(By.XPATH, non_exclusive_xpath, timeout=30): + raise Exception("Could not choose the non-exclusive publisher account option.") + + publisher_name_xpath = ( + "//input[(not(@type) or @type='text') and " + "(contains(translate(@name, 'PUBLISHER', 'publisher'), 'publisher') or " + "contains(translate(@id, 'PUBLISHER', 'publisher'), 'publisher'))] | " + "//label[contains(normalize-space(.), 'Publisher Account Name')]/following::input[1]" + ) + publisher_name_input = WebDriverWait(self.driver, 30).until( + presence_of_element_located((By.XPATH, publisher_name_xpath)) + ) + publisher_name_input.clear() + publisher_name_input.send_keys("MPC Autofill Publisher") + + agreement_xpath = ( + "//input[@type='checkbox' and " + "(contains(translate(@name, 'AGREE', 'agree'), 'agree') or " + "contains(translate(@id, 'AGREE', 'agree'), 'agree'))] | " + "//label[contains(normalize-space(.), 'I Agree')]//input[@type='checkbox']" + ) + agreement_checkbox = WebDriverWait(self.driver, 30).until(element_to_be_clickable((By.XPATH, agreement_xpath))) + if not agreement_checkbox.is_selected() and not self.click_element_with_retry(agreement_checkbox): + raise Exception("Could not accept the publisher agreement.") + + setup_xpath = ( + "//*[self::a or self::button or self::input]" + "[contains(normalize-space(.), 'Set up My Publisher Account') " + "or contains(@value, 'Set up My Publisher Account')]" + ) + if not self.click_element_polling(By.XPATH, setup_xpath, timeout=30): + raise Exception("Could not submit the publisher agreement.") + + save_xpath = "//*[self::button or self::input]" "[normalize-space(.)='Save' or @value='Save']" + if not self.click_element_polling(By.XPATH, save_xpath, timeout=30): + raise Exception("Could not save the publisher payment information.") + + try: + WebDriverWait(self.driver, 30, poll_frequency=0.5).until(lambda _driver: self.is_dtc_publisher_ready()) + except sl_exc.TimeoutException as exc: + raise Exception("Publisher setup finished, but the Publish tab did not appear.") from exc + + logger.info("DriveThruCards publisher account setup is complete.") + + def navigate_to_dtc_product_setup(self) -> None: + """ + Navigate through DriveThruCards to the product setup page. + Steps: Publisher Tools -> Set up a new title + """ + self.set_state(States.defining_order, "Navigating to Publisher Tools") + selectors = self.target_site.value.selectors + + # Step 1: Try quick click first to avoid post-login pause; otherwise navigate directly. + publisher_clicked = self.click_element_polling(By.CSS_SELECTOR, selectors.publisher_ready_selector, timeout=1) + if publisher_clicked: + logger.debug("Clicked 'Publisher Tools' link.") + else: + logger.warning("Could not find 'Publisher Tools' link. Trying direct navigation...") + self.driver.get("https://site.drivethrucards.com/pub_tools.php") + + # Step 2: Try quick click for setup link; otherwise navigate directly. + self.set_state(States.defining_order, "Navigating to product setup") + setup_clicked = self.click_element_polling(By.XPATH, "//a[contains(@href, 'pub_enter_product.php')]", timeout=2) + if setup_clicked: + logger.debug("Clicked 'Set up a new title' link.") + else: + logger.warning("Could not find 'Set up a new title' link. Trying direct navigation...") + self.driver.get("https://tools.drivethrucards.com/pub_enter_product.php") + + def fill_dtc_product_form(self, order: CardOrder) -> None: + """ + Fill out the DriveThruCards product setup form (first page). + """ + self.set_state(States.defining_order, "Filling product form") + + # Get the placeholder cover image path (bundled with assets) + assets_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "assets") + placeholder_cover_path = os.path.join(assets_dir, "placeholder_cover.png") + + # Generate title: order name + today's date + today = dt.date.today().strftime("%Y-%m-%d") + title = f"{order.name or 'Order'} {today}" + + # All of these fields are required for the product to be set up correctly, + # so any missing element must abort the order rather than limp on. + title_input = WebDriverWait(self.driver, 10).until(presence_of_element_located((By.ID, "products_name"))) + title_input.clear() + title_input.send_keys(title) + logger.debug(f"Set product title to: {title}") + + price_input = self.driver.find_element(By.ID, "options_values_total_price") + price_input.clear() + price_input.send_keys("0") + logger.debug("Set special price to: 0") + + self.driver.find_element(By.NAME, "products_image").send_keys(placeholder_cover_path) + logger.debug(f"Uploaded placeholder cover image: {placeholder_cover_path}") + + for checkbox_id in ("filter_44550", "filter_1000138"): + checkbox = self.driver.find_element(By.ID, checkbox_id) + if not checkbox.is_selected() and not self.click_element_with_retry(checkbox): + raise Exception(f"Could not check the {checkbox_id} product filter checkbox.") + logger.debug(f"Checked {checkbox_id}") + + # Click the first submit button (Save Title Data and Continue to Preview Description) + self.set_state(States.defining_order, "Submitting product form") + submit_button = WebDriverWait(self.driver, 10).until(element_to_be_clickable((By.ID, "submit_id"))) + if not self.click_element_with_retry(submit_button): + raise Exception("Could not click the product form submit button.") + logger.debug("Product form submitted successfully.") + + def submit_dtc_description_page(self) -> None: + """ + Click 'Save and Continue' on the description preview page. + Waits for the button to be available (page loaded from previous step). + """ + self.set_state(States.defining_order, "Saving description") + save_continue_button = WebDriverWait(self.driver, 15).until(element_to_be_clickable((By.ID, "clicked_element"))) + if not self.click_element_with_retry(save_continue_button): + raise Exception("Could not click the 'Save and Continue' button on the description page.") + logger.debug("Description page submitted.") + + def open_dtc_upload_page(self) -> None: + """ + Navigate to the upload page by extracting the URL from the 'Upload print-ready file' + button's onclick attribute, rather than letting it open a new tab via window.open(). + """ + self.set_state(States.defining_order, "Opening upload page") + + upload_button = WebDriverWait(self.driver, 15).until( + element_to_be_clickable((By.XPATH, "//button[contains(@onclick, 'pub_upload_podcard_files.php')]")) + ) + onclick = upload_button.get_attribute("onclick") or "" + # Extract URL from onclick like: window.open('https://...pub_upload_podcard_files.php?products_id=123'); + url_match = re.search(r"window\.open\(['\"]([^'\"]+)['\"]\)", onclick) + if not url_match: + raise Exception(f"Could not extract the upload page URL from the upload button (onclick: {onclick}).") + self.driver.execute_script("window.location.href = arguments[0];", url_match.group(1)) + # Wait for the upload page to load + WebDriverWait(self.driver, 15).until(presence_of_element_located((By.ID, "card_type_select"))) + logger.debug(f"Navigated to upload page: {self.driver.current_url}") + + def select_card_type_and_upload_pdf(self, pdf_path: str) -> None: + """ + Select 'Premium Euro Poker Card(s)' from dropdown and upload the PDF. + Waits for elements to be available instead of using fixed sleeps. + """ + self.set_state(States.inserting_fronts, "Selecting card type") + + # Wait for and select the Euro Poker card option from the dropdown. + # Any missing required element below aborts the order - a partial upload must not report success. + WebDriverWait(self.driver, 15).until(presence_of_element_located((By.ID, "card_type_select"))) + # Re-fetch the element to avoid stale reference after page/tab switch + select = Select(self.driver.find_element(By.ID, "card_type_select")) + + # Find option containing "Euro Poker" (case-insensitive search) + euro_poker_option_text = next( + (option.text for option in select.options if "euro poker" in option.text.lower()), None + ) + if euro_poker_option_text is None: + raise Exception("Could not find the Euro Poker option in the card type dropdown.") + select.select_by_visible_text(euro_poker_option_text) + logger.debug(f"Selected '{euro_poker_option_text}' from dropdown.") + + # Convert PDF path to absolute if needed + if not os.path.isabs(pdf_path): + pdf_path = os.path.abspath(pdf_path) + + if not os.path.exists(pdf_path): + raise Exception(f"PDF file not found: {pdf_path}") + + logger.debug(f"PDF file found: {pdf_path} ({os.path.getsize(pdf_path)} bytes)") + + # Wait for the dropzone to appear after card type selection + self.set_state(States.inserting_fronts, "Uploading PDF") + dropzone_div = WebDriverWait(self.driver, 15).until(presence_of_element_located((By.ID, "uploadfiles"))) + logger.debug("Dropzone div found.") + + # Click the dropzone to initialize Dropzone's hidden input + # This should create the .dz-hidden-input element + logger.debug("Clicking dropzone to initialize hidden input...") + self.driver.execute_script("arguments[0].click();", dropzone_div) + + # Brief wait for the file dialog to appear, then send Escape to close it + time.sleep(0.5) + ActionChains(self.driver).send_keys(Keys.ESCAPE).perform() + time.sleep(0.5) + + # Find the file input and send the file - do this in a single operation + # to avoid stale element references + logger.debug(f"Uploading PDF: {pdf_path}") + + def find_and_use_file_input() -> bool: + """Find a usable file input and send the file path to it.""" + # Strategy 1: Dropzone hidden input + try: + fi = self.driver.find_element(By.CSS_SELECTOR, ".dz-hidden-input") + logger.debug("Found Dropzone hidden input, sending file...") + fi.send_keys(pdf_path) + return True + except (sl_exc.NoSuchElementException, sl_exc.StaleElementReferenceException): + pass + + # Strategy 2: Any file input that's not the fallback + try: + file_inputs = self.driver.find_elements(By.CSS_SELECTOR, "input[type='file']") + logger.debug(f"Found {len(file_inputs)} file input(s) on page.") + for fi in file_inputs: + try: + name = fi.get_attribute("name") + if name == "groups_csv": + continue + logger.debug(f"Trying file input: name={name}") + fi.send_keys(pdf_path) + return True + except sl_exc.StaleElementReferenceException: + continue + except Exception as e: + logger.debug(f"Error with file inputs: {e}") + + # Strategy 3: Use the fallback input + try: + logger.debug("Using fallback file input...") + self.driver.execute_script("document.getElementById('dropzoneFallback').style.display = 'block';") + fi = self.driver.find_element(By.CSS_SELECTOR, "#dropzoneFallback input[type='file']") + fi.send_keys(pdf_path) + return True + except Exception as e: + logger.debug(f"Fallback input failed: {e}") + + return False + + # Try up to 3 times to handle any remaining stale element issues + file_sent = False + for attempt in range(3): + if find_and_use_file_input(): + file_sent = True + break + logger.debug(f"Attempt {attempt + 1} failed, retrying...") + time.sleep(0.5) + + if not file_sent: + raise Exception("Could not send the PDF to any file input element on the upload page.") + + # Trigger change event on all file inputs (one of them has our file) + self.driver.execute_script( + """ + document.querySelectorAll('input[type="file"]').forEach(function(input) { + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + """ + ) + logger.debug("Dispatched change event on file inputs.") + + # Wait for the upload button to become enabled (Dropzone enables it when files are queued) + try: + WebDriverWait(self.driver, 10).until( + lambda d: not d.find_element(By.ID, "dropzoneButton").get_attribute("disabled") + ) + logger.debug("Upload button is now enabled.") + except sl_exc.TimeoutException: + logger.debug("Button didn't become enabled automatically, forcing it.") + + # Click the upload button using JavaScript + upload_clicked = self.driver.execute_script( + """ + var btn = document.getElementById('dropzoneButton'); + if (btn) { + btn.disabled = false; + btn.style.display = 'block'; + btn.click(); + return true; + } + return false; + """ + ) + if not upload_clicked: + raise Exception("Could not find the 'Begin Card File Upload' button.") + logger.debug("Clicked 'Begin Card File Upload' button via JavaScript.") + + # Also try to trigger Dropzone's processQueue as a backup + try: + self.driver.execute_script( + """ + var dz = Dropzone.forElement('#uploadfiles'); + if (dz && dz.files && dz.files.length > 0) { + dz.processQueue(); + } + """ + ) + logger.debug("Triggered Dropzone processQueue.") + except Exception as e: + logger.debug(f"Could not trigger processQueue: {e}") + + # Wait for upload to complete - look for success message + try: + WebDriverWait(self.driver, 120).until( + lambda d: "successfully uploaded" + in ( + d.find_element(By.ID, "status_messages").text.lower() + if d.find_elements(By.ID, "status_messages") + else "" + ) + ) + except sl_exc.TimeoutException as exc: + raise Exception("The PDF upload was not confirmed by DriveThruCards within 120 seconds.") from exc + logger.debug("PDF upload completed to DriveThruCards.") + + # Wait for the continue button to become active. + # The button starts with an onclick handler that shows an error and returns false. + # After upload validation, the page JS replaces this handler to enable navigation. + # We detect activation by checking that the onclick no longer contains "return false". + def continue_button_is_active(d: Any) -> Any: + btn = d.find_element(By.ID, "continue_button") + onclick = btn.get_attribute("onclick") or "" + if "return false" in onclick: + return False + return btn + + try: + continue_button = WebDriverWait(self.driver, 60).until(continue_button_is_active) + except sl_exc.TimeoutException as exc: + raise Exception("The continue button did not activate after the PDF upload.") from exc + logger.debug(f"Continue button activated. onclick: {continue_button.get_attribute('onclick')}") + self.driver.execute_script("arguments[0].click();", continue_button) + logger.debug("Clicked 'Click here after uploading your files' button.") + + # Click the "Complete Setup" button on the next page + complete_button = WebDriverWait(self.driver, 30).until(element_to_be_clickable((By.ID, "submit_id"))) + self.driver.execute_script("arguments[0].click();", complete_button) + logger.debug("Clicked 'Complete Setup' button.") + + # Click the "buy now" link to start placing the order. + # This link has target="_blank", so navigate directly to avoid new-tab issues. + buy_now_link = WebDriverWait(self.driver, 30).until( + element_to_be_clickable((By.CSS_SELECTOR, "a[href*='action=buy_now']")) + ) + buy_now_href = buy_now_link.get_attribute("href") + if buy_now_href: + self.driver.get(buy_now_href) + logger.debug("Navigated to 'buy now' page to start placing the order.") + else: + self.driver.execute_script("arguments[0].click();", buy_now_link) + logger.debug("Clicked 'buy now' link.") + + def execute_drive_thru_cards_order(self, order: CardOrder, pdf_path: str) -> None: + t = time.time() + self.set_state(States.defining_order, "Opening DriveThruCards") + + def run_step(step_name: str, func: Any, *args: Any, **kwargs: Any) -> Any: + try: + return func(*args, **kwargs) + except Exception as exc: + raise Exception(f"DriveThruCards step '{step_name}' failed: {exc}") from exc + + run_step("open_dtc_starting_page", self.open_dtc_starting_page) + run_step("wait_for_cloudflare_challenge", self.wait_for_cloudflare_challenge) + login_completed = run_step("authenticate_dtc", self.authenticate_dtc) + if not login_completed: + raise Exception( + "DriveThruCards login was not completed before timeout. " "Please log in and re-run the command." + ) + run_step("ensure_dtc_publisher_account", self.ensure_dtc_publisher_account) + run_step("navigate_to_dtc_product_setup", self.navigate_to_dtc_product_setup) + run_step("fill_dtc_product_form", self.fill_dtc_product_form, order) + run_step("submit_dtc_description_page", self.submit_dtc_description_page) + run_step("open_dtc_upload_page", self.open_dtc_upload_page) + run_step("select_card_type_and_upload_pdf", self.select_card_type_and_upload_pdf, pdf_path) + + # DriveThruCards automation complete - user should finish checkout manually + self.set_state(States.finished, "Complete your purchase in the browser") + log_hours_minutes_seconds_elapsed(t) + logger.info( + "DriveThruCards order setup complete!\n" + "You are now at the checkout page. Please review your order and complete the purchase manually." + ) + + # endregion + # region uploading @exception_retry_skip_handler @@ -804,6 +1530,8 @@ def execute_order( ) -> None: t = time.time() self.configure_bars_for_order(order=order) + # complete all downloads before any browser automation so a failed image stops the + # order before anything is uploaded, rather than leaving a partially-filled project with ThreadPoolExecutor(max_workers=THREADS) as pool: order.fronts.download_images( pool=pool, download_bar=self.download_bar, post_processing_config=post_processing_config @@ -811,35 +1539,38 @@ def execute_order( order.backs.download_images( pool=pool, download_bar=self.download_bar, post_processing_config=post_processing_config ) - if any( - [ - fulfilment_method == OrderFulfilmentMethod.append_to_project, - fulfilment_method == OrderFulfilmentMethod.continue_project, - auto_save_threshold is not None, - ] - ): - self.authenticate() - - self.initialise_order(order=order) - if fulfilment_method == OrderFulfilmentMethod.new_project: - logger.info("Configuring a new project.") - self.define_project(order=order) - self.page_to_fronts(order=order) - else: - order = self.redefine_project(order=order, fulfilment_method=fulfilment_method) + failed_images = order.get_failed_downloads() + if failed_images: + raise ImageDownloadError(failed_images) + + if any( + [ + fulfilment_method == OrderFulfilmentMethod.append_to_project, + fulfilment_method == OrderFulfilmentMethod.continue_project, + auto_save_threshold is not None, + ] + ): + self.authenticate() + + self.initialise_order(order=order) + if fulfilment_method == OrderFulfilmentMethod.new_project: + logger.info("Configuring a new project.") + self.define_project(order=order) + self.page_to_fronts(order=order) + else: + order = self.redefine_project(order=order, fulfilment_method=fulfilment_method) - self.insert_fronts(order=order, auto_save_threshold=auto_save_threshold) - self.page_to_backs(order=order) - self.insert_backs(order=order, auto_save_threshold=auto_save_threshold) - self.page_to_review() + self.insert_fronts(order=order, auto_save_threshold=auto_save_threshold) + self.page_to_backs(order=order) + self.insert_backs(order=order, auto_save_threshold=auto_save_threshold) + self.page_to_review() log_hours_minutes_seconds_elapsed(t) logger.info( textwrap.dedent( f""" Please review your project and ensure everything has been uploaded correctly before finalising with - {self.target_site.name}. If any images failed to download, links to download them will have been printed - above. If you need to make any changes to your order, you can do so by adding it to your Saved Projects - and editing in your normal browser. + {self.target_site.name}. If you need to make any changes to your order, you can do so by adding it + to your Saved Projects and editing in your normal browser. """ ) ) diff --git a/desktop-tool/src/exc.py b/desktop-tool/src/exc.py index ba2ca1d22..8e173a250 100644 --- a/desktop-tool/src/exc.py +++ b/desktop-tool/src/exc.py @@ -1,4 +1,5 @@ from src.formatting import bold +from src.logging import CRASH_LOG_FILENAME class InvalidStateException(Exception): @@ -13,3 +14,19 @@ def __init__(self, state: str, expected_state: str): class ValidationException(Exception): pass + + +class ImageDownloadError(Exception): + def __init__(self, failed_images: list[tuple[str, str]]) -> None: + failed_list = "\n".join( + f"- {name or 'Unknown image'}" + (f" (Drive ID: {drive_id})" if drive_id else "") + for name, drive_id in failed_images + ) + super().__init__( + "Some card images could not be downloaded, so the tool has stopped before creating your order.\n" + f"{failed_list}\n\n" + "This usually means the saved XML refers to an image that was removed or replaced after the order " + f"was created. Import this XML into a new project at {bold('https://mpcfill.com/editor')} to identify " + "the unmatched cards and choose replacements, then download a new XML and try again. " + f"Technical details were saved to {CRASH_LOG_FILENAME}." + ) diff --git a/desktop-tool/src/icc.py b/desktop-tool/src/icc.py new file mode 100644 index 000000000..19f526f85 --- /dev/null +++ b/desktop-tool/src/icc.py @@ -0,0 +1,91 @@ +""" +Locate or download the US Web Coated (SWOP) ICC profile used for DriveThruCards PDF/X-1a output. + +Adobe's Color Profile License Agreement permits using the profile and embedding it in image files, +but not redistributing it bundled with application software - so instead of shipping the profile, +we look for a copy already installed on this system and otherwise download Adobe's own end-user +bundle (with the user's consent, accepting Adobe's license terms directly). +""" + +import hashlib +import io +import os +import sys +import zipfile +from pathlib import Path +from typing import Optional + +import click +import requests + +from src.formatting import bold +from src.logging import logger + +ICC_PROFILE_FILENAME = "USWebCoatedSWOP.icc" +ADOBE_ICC_BUNDLE_URL = "https://download.adobe.com/pub/adobe/iccprofiles/win/AdobeICCProfilesCS4Win_end-user.zip" +ADOBE_ICC_BUNDLE_MEMBER = f"Adobe ICC Profiles (end-user)/CMYK/{ICC_PROFILE_FILENAME}" +ADOBE_ICC_LICENSE_URL = "https://www.adobe.com/support/downloads/iccprofiles/icc_eula_win_end.html" +ICC_PROFILE_SHA256 = "35f401731df11a4eba3502af632e51d68bc394bcb7d34632a331c1ba3f4a0bf6" + + +def get_profile_cache_path() -> Path: + return Path.home() / ".mpc-autofill" / ICC_PROFILE_FILENAME + + +def _candidate_profile_paths() -> list[Path]: + home = Path.home() + if sys.platform == "darwin": + directories = [ + Path("/Library/Application Support/Adobe/Color/Profiles/Recommended"), + Path("/Library/ColorSync/Profiles"), + home / "Library/ColorSync/Profiles", + ] + elif sys.platform == "win32": + system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) + program_files = Path(os.environ.get("ProgramFiles", r"C:\Program Files")) + directories = [ + system_root / "System32/spool/drivers/color", + program_files / "Common Files/Adobe/Color/Profiles/Recommended", + ] + else: + directories = [Path("/usr/share/color/icc"), home / ".local/share/icc", home / ".color/icc"] + return [directory / ICC_PROFILE_FILENAME for directory in directories] + [get_profile_cache_path()] + + +def _download_profile() -> Optional[str]: + logger.info(f"Downloading the Adobe ICC profile bundle from {bold(ADOBE_ICC_BUNDLE_URL)}...") + try: + response = requests.get(ADOBE_ICC_BUNDLE_URL, timeout=120) + response.raise_for_status() + profile_bytes = zipfile.ZipFile(io.BytesIO(response.content)).read(ADOBE_ICC_BUNDLE_MEMBER) + except Exception as exc: + logger.warning(f"Failed to download the ICC profile: {exc}") + return None + if hashlib.sha256(profile_bytes).hexdigest() != ICC_PROFILE_SHA256: + logger.warning("The downloaded ICC profile did not match the expected checksum - not using it.") + return None + cache_path = get_profile_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(profile_bytes) + logger.info(f"ICC profile saved to {bold(str(cache_path))}.") + return str(cache_path) + + +def find_or_download_dtc_icc_profile() -> Optional[str]: + """ + Return a path to the US Web Coated (SWOP) ICC profile, or None if it's unavailable + (not installed, and the user declined or the download failed). + """ + for candidate in _candidate_profile_paths(): + if candidate.is_file(): + return str(candidate) + logger.info( + "DriveThruCards colour conversion works best with the US Web Coated (SWOP) ICC profile, " + "which was not found on this system." + ) + if not click.confirm( + f"Download it from Adobe now? (Subject to Adobe's license terms: {ADOBE_ICC_LICENSE_URL})", + default=True, + ): + return None + return _download_profile() diff --git a/desktop-tool/src/io.py b/desktop-tool/src/io.py index a081a0c74..b45bb19ba 100644 --- a/desktop-tool/src/io.py +++ b/desktop-tool/src/io.py @@ -13,8 +13,13 @@ from oauth2client.service_account import ServiceAccountCredentials import src.constants as constants -from src.logging import logger -from src.processing import ImagePostProcessingConfig, post_process_image +from src.logging import FILE_ONLY, logger +from src.processing import ( + ImagePostProcessingConfig, + get_post_processed_path, + post_process_image, + save_processed_image, +) thread_local = threading.local() # Should only be called once per thread @@ -189,13 +194,17 @@ def download_google_drive_file( _, done = downloader.next_chunk() file_bytes = file.getvalue() except HttpError: - logger.exception(f"Encountered a HTTP error while downloading Google Drive image {drive_id}") + logger.exception(f"Encountered a HTTP error while downloading Google Drive image {drive_id}", extra=FILE_ONLY) return False if post_processing_config is not None: logger.debug(f"Post-processing {drive_id}...") - processed_image = post_process_image(raw_image=file_bytes, config=post_processing_config) - processed_image.save(file_path) + output_path = get_post_processed_path(file_path=file_path, config=post_processing_config) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + processed_image, icc_profile_bytes = post_process_image(raw_image=file_bytes, config=post_processing_config) + save_processed_image( + processed_image, file_path=output_path, config=post_processing_config, icc_profile_bytes=icc_profile_bytes + ) else: # Save the bytes directly to disk - avoid reading in pillow in case any quality degradation occurs with open(file_path, "wb") as f: diff --git a/desktop-tool/src/logging.py b/desktop-tool/src/logging.py index 585f6b017..f5323acd4 100644 --- a/desktop-tool/src/logging.py +++ b/desktop-tool/src/logging.py @@ -6,6 +6,16 @@ logger = logging.getLogger(__name__) +CRASH_LOG_FILENAME = "autofill_crash_log.txt" + +# pass as `extra` to log calls whose details belong in the crash log but not on the console, +# e.g. per-image download failures that are also reported in a user-facing summary +FILE_ONLY = {"console": False} + + +def _console_visible(record: logging.LogRecord) -> bool: + return getattr(record, "console", True) + class FileLogFormatter(logging.Formatter): # A custom formatter which removes bold start/end characters from records before writing to disk @@ -15,6 +25,18 @@ def format(self, record: logging.LogRecord) -> str: return super().format(new_record) +class ConsoleFormatter(logging.Formatter): + # Hides exception tracebacks from the console - they're still written in full to the crash log. + # Formats a copy so the original record's traceback isn't stripped for other handlers. + def format(self, record: logging.LogRecord) -> str: + if record.exc_info or record.exc_text or record.stack_info: + record = copy(record) + record.exc_info = None + record.exc_text = None + record.stack_info = None + return super().format(record) + + def configure_loggers(working_directory: str, log_debug_to_file: bool, stdout_log_level: int) -> None: logging.getLogger("googleapiclient").setLevel(logging.ERROR) logging.getLogger("oauth2client").setLevel(logging.ERROR) @@ -28,11 +50,15 @@ def configure_loggers(working_directory: str, log_debug_to_file: bool, stdout_lo stdout_handler.setLevel(stdout_log_level) if stdout_log_level <= logging.DEBUG: # If the user has opted into debug logging, format stdout logs with their log level + # and keep exception tracebacks visible on the console console_debug_format_string = "[%(levelname)s] %(message)s" stdout_handler.setFormatter(logging.Formatter(console_debug_format_string)) + else: + stdout_handler.setFormatter(ConsoleFormatter()) + stdout_handler.addFilter(_console_visible) logger.addHandler(stdout_handler) - file_crash_logger = logging.FileHandler(os.path.join(working_directory, "autofill_crash_log.txt")) + file_crash_logger = logging.FileHandler(os.path.join(working_directory, CRASH_LOG_FILENAME)) file_crash_logger.setLevel(logging.ERROR) file_crash_logger.setFormatter(FileLogFormatter(file_debug_format_string)) logger.addHandler(file_crash_logger) diff --git a/desktop-tool/src/order.py b/desktop-tool/src/order.py index ad03df03a..9db08ac5b 100644 --- a/desktop-tool/src/order.py +++ b/desktop-tool/src/order.py @@ -26,11 +26,30 @@ get_google_drive_file_name, get_image_directory, ) -from src.logging import logger +from src.logging import FILE_ONLY, logger from src.processing import ImagePostProcessingConfig from src.utils import unpack_element +def is_image_valid(file_path: str) -> bool: + try: + from PIL import Image + + with Image.open(file_path) as img: + img.verify() + return True + except Exception: + return False + + +def remove_if_exists(file_path: str) -> None: + try: + if os.path.isfile(file_path): + os.remove(file_path) + except Exception: + pass + + @attr.s class CardImage: drive_id: str = attr.ib(default="") @@ -181,28 +200,53 @@ def download_image( try: if self.source_type == SourceType.LOCAL_FILE: if self.file_exists() and not self.errored: - self.downloaded = True + if is_image_valid(cast(str, self.file_path)): + self.downloaded = True + else: + logger.error( + f"Local file '{bold(self.name)}' appears to be corrupted at path:\n" + f"{bold(self.file_path)}\n", + extra=FILE_ONLY, + ) + self.errored = True else: - logger.info(f"Local file '{bold(self.name)}' does not exist at path:\n{bold(self.drive_id)}\n") - elif self.source_type == SourceType.GOOGLE_DRIVE: - if not self.file_exists() and not self.errored and self.file_path is not None: - self.errored = not download_google_drive_file( - drive_id=self.drive_id, file_path=self.file_path, post_processing_config=post_processing_config + logger.error( + f"Local file '{bold(self.name)}' does not exist at path:\n{bold(self.drive_id)}\n", + extra=FILE_ONLY, ) + elif self.source_type == SourceType.GOOGLE_DRIVE: + if self.file_path is not None and not self.errored: + for attempt in range(2): + if not self.file_exists(): + self.errored = not download_google_drive_file( + drive_id=self.drive_id, + file_path=self.file_path, + post_processing_config=post_processing_config, + ) + if self.file_exists() and not self.errored and is_image_valid(cast(str, self.file_path)): + break + if self.file_exists(): + remove_if_exists(cast(str, self.file_path)) + logger.info(f"Downloaded image '{bold(self.name)}' appears corrupted. Retrying download...") + if attempt == 1: + self.errored = True if self.file_exists() and not self.errored: self.downloaded = True else: - logger.info( + logger.error( f"Failed to download '{bold(self.name)}' - allocated to slot/s {bold(sorted(self.slots))}.\n" - f"Download link - {bold(f'https://drive.google.com/uc?id={self.drive_id}&export=download')}\n" + f"Download link - {bold(f'https://drive.google.com/uc?id={self.drive_id}&export=download')}\n", + extra=FILE_ONLY, ) except Exception as e: # note: python threads die silently if they encounter an exception. if an exception does occur, # log it, but still put the card onto the queue so the main thread doesn't spin its wheels forever waiting. - logger.info( + logger.exception( f"An uncaught exception occurred when attempting to download '{bold(self.name)}':\n{bold(e)}\n" - f"Download link - {bold(f'https://drive.google.com/uc?id={self.drive_id}&export=download')}\n" + f"Allocated to slot/s {bold(sorted(self.slots))}.\n" + f"Download link - {bold(f'https://drive.google.com/uc?id={self.drive_id}&export=download')}\n", + extra=FILE_ONLY, ) finally: queue.put((self.drive_id, self.downloaded)) @@ -672,6 +716,20 @@ def from_multiple_orders(cls, orders: list["CardOrder"]) -> "CardOrder": assert len(orders) > 0, "Attempted to produce a CardOrder from multiple CardOrders but none were given!" return reduce(lambda a, b: a.combine(b), orders) + def get_failed_downloads(self) -> list[tuple[str, str]]: + """ + Return (name, drive_id) for each image in this order which has not been downloaded successfully. + """ + + return sorted( + { + (card.name or "Unknown image", card.drive_id) + for collection in (self.fronts, self.backs) + for card in collection.cards_by_id.values() + if not card.downloaded + } + ) + def get_overview(self) -> str: return ( f"Total of {bold(self.details.quantity)} cards. " diff --git a/desktop-tool/src/pdf_maker.py b/desktop-tool/src/pdf_maker.py index 3c722748a..d728d7bfb 100644 --- a/desktop-tool/src/pdf_maker.py +++ b/desktop-tool/src/pdf_maker.py @@ -1,5 +1,11 @@ import os +import shutil +import subprocess +import sys +import tempfile from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path from typing import Optional import attr @@ -8,10 +14,172 @@ from fpdf import FPDF from src.constants import THREADS, States +from src.exc import ImageDownloadError from src.formatting import bold from src.logging import logger from src.order import CardOrder -from src.processing import ImagePostProcessingConfig +from src.processing import ( + DTC_CARD_HEIGHT_INCHES, + DTC_CARD_WIDTH_INCHES, + ImagePostProcessingConfig, + calculate_dtc_target_pixel_size, + post_process_image, + save_processed_image, +) + + +@dataclass +class PdfXConversionConfig: + icc_profile_path: Optional[str] = None + ghostscript_path: Optional[str] = None + + +def get_export_directory(order_name: Optional[str]) -> str: + basename = os.path.basename(str(order_name)) or "cards.xml" + return os.path.join("export", os.path.splitext(basename)[0]) + + +def get_ghostscript_path(explicit_path: Optional[str] = None) -> Optional[str]: + if explicit_path: + return explicit_path + for candidate in ["gs", "gswin64c", "gswin32c"]: + if resolved := shutil.which(candidate): + return resolved + if sys.platform == "win32": + installs = list(Path(os.environ.get("ProgramFiles", r"C:\Program Files")).glob("gs/gs*/bin/gswin*c.exe")) + if installs: + return str(max(installs, key=os.path.getmtime)) + return None + + +def get_ghostscript_version(gs_path: str) -> Optional[str]: + try: + result = subprocess.run([gs_path, "-version"], capture_output=True, text=True, check=False) + except Exception: + return None + version = result.stdout.strip().splitlines() + return version[0] if version else None + + +def _postscript_string(value: str) -> str: + return "(" + value.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") + ")" + + +def _generate_pdfx_definition(title: str, icc_profile_path: Optional[str]) -> str: + """ + Generate the PDF/X-1a definition PostScript (based on Ghostscript's lib/PDFX_def.ps) which + supplies the metadata and output intent that the PDF/X-1a standard requires. The output is + tagged for the US Web Coated (SWOP) printing condition which DriveThruCards expects; when an + ICC profile is available it is embedded as the output intent's destination profile. + """ + lines = [ + "%!", + "% PDF/X-1a definition file generated by the MPC Autofill desktop tool.", + "[ /GTS_PDFXVersion (PDF/X-1:2001)", + " /GTS_PDFXConformance (PDF/X-1a:2001)", + f" /Title {_postscript_string(title)}", + " /Trapped /False", + " /DOCINFO pdfmark", + "[/_objdef {OutputIntent_PDFX} /type /dict /OBJ pdfmark", + "[{OutputIntent_PDFX} <<", + " /Type /OutputIntent", + " /S /GTS_PDFX", + " /OutputCondition (US Web Coated \\(SWOP\\))", + " /Info (U.S. Web Coated \\(SWOP\\) v2)", + " /OutputConditionIdentifier (CGATS TR 001)", + " /RegistryName (http://www.color.org)", + ">> /PUT pdfmark", + ] + if icc_profile_path: + posix_icc_path = Path(icc_profile_path).as_posix() + lines[2:2] = [f"/ICCProfile {_postscript_string(posix_icc_path)} def"] + lines += [ + "[/_objdef {icc_PDFX} /type /stream /OBJ pdfmark", + "[{icc_PDFX} << /N 4 >> /PUT pdfmark", + "[{icc_PDFX} ICCProfile (r) file /PUT pdfmark", + "[{OutputIntent_PDFX} << /DestOutputProfile {icc_PDFX} >> /PUT pdfmark", + ] + lines += ["[{Catalog} << /OutputIntents [ {OutputIntent_PDFX} ] >> /PUT pdfmark", ""] + return "\n".join(lines) + + +def verify_pdfx_output(output_path: str) -> bool: + """ + Lightweight preflight of the converted file: Ghostscript writes the document info dictionary + and output intent uncompressed, so their absence proves the file is not the PDF/X-1a we asked + for (a zero exit code from Ghostscript alone does not). + """ + with open(output_path, "rb") as f: + contents = f.read() + return b"(PDF/X-1a:2001)" in contents and b"/OutputIntents" in contents + + +def convert_pdf_to_pdfx( + source_path: str, + output_path: str, + config: PdfXConversionConfig, +) -> bool: + gs_path = get_ghostscript_path(config.ghostscript_path) + if not gs_path: + logger.warning("Ghostscript was not found. Skipping PDF/X-1a conversion.") + return False + + output_directory = os.path.dirname(output_path) or "." + os.makedirs(output_directory, exist_ok=True) + fd, temporary_output_path = tempfile.mkstemp( + dir=output_directory, + prefix=f".{os.path.splitext(os.path.basename(output_path))[0]}-", + suffix=".pdf", + ) + os.close(fd) + + with tempfile.NamedTemporaryFile("w", suffix=".ps", delete=False) as pdfx_definition_file: + pdfx_definition_file.write( + _generate_pdfx_definition( + title=os.path.splitext(os.path.basename(output_path))[0], icc_profile_path=config.icc_profile_path + ) + ) + pdfx_definition_path = pdfx_definition_file.name + + cmd = [ + gs_path, + "-dBATCH", + "-dNOPAUSE", + "-dNOSAFER", # Allow file system access for ICC profile and output + "-sDEVICE=pdfwrite", + "-dCompatibilityLevel=1.3", + "-dPDFX=1", # PDF/X-1; conformance level PDF/X-1a:2001 is declared in the definition file + "-dDownsampleColorImages=false", + "-dDownsampleGrayImages=false", + "-dDownsampleMonoImages=false", + "-sProcessColorModel=DeviceCMYK", + "-sColorConversionStrategy=CMYK", + f"-sOutputFile={temporary_output_path}", + ] + if config.icc_profile_path: + cmd.append(f"-sOutputICCProfile={config.icc_profile_path}") + cmd += [pdfx_definition_path, source_path] + + logger.debug(f"Ghostscript command: {' '.join(cmd)}") + try: + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + logger.warning( + "Ghostscript failed to convert PDF/X-1a.\n" f"stdout: {result.stdout}\n" f"stderr: {result.stderr}" + ) + return False + if not verify_pdfx_output(temporary_output_path): + logger.warning( + "Ghostscript exited successfully but the output failed PDF/X-1a verification " + "(missing PDF/X-1a version marker or output intent)." + ) + return False + os.replace(temporary_output_path, output_path) + return True + finally: + os.unlink(pdfx_definition_path) + if os.path.exists(temporary_output_path): + os.unlink(temporary_output_path) @attr.s @@ -27,21 +195,30 @@ class PdfExporter: save_path: str = attr.ib(default="") separate_faces: bool = attr.ib(default=False) current_face: str = attr.ib(default="all") + export_mode: str = attr.ib(default="standard") + pdfx_config: Optional[PdfXConversionConfig] = attr.ib(default=None) + image_post_processing_config: Optional[ImagePostProcessingConfig] = attr.ib(default=None) manager: enlighten.Manager = attr.ib(init=False, default=attr.Factory(enlighten.get_manager)) status_bar: enlighten.StatusBar = attr.ib(init=False, default=False) download_bar: enlighten.Counter = attr.ib(init=False, default=None) processed_bar: enlighten.Counter = attr.ib(init=False, default=None) + saved_files: list[str] = attr.ib(init=False, factory=list) + processed_image_paths: dict[str, str] = attr.ib(init=False, factory=dict) def configure_bars(self) -> None: num_images = len(self.order.fronts.cards_by_id) + len(self.order.backs.cards_by_id) + num_cards = len(self.order.fronts.slots()) status_format = "State: {state}" self.status_bar = self.manager.status_bar( status_format=status_format, state=f"{bold(self.state)}", position=1, + leave=False, # transient - cleared when the export finishes, unlike the counters below ) - self.download_bar = self.manager.counter(total=num_images, desc="Images Downloaded", position=2) - self.processed_bar = self.manager.counter(total=num_images, desc="Images Processed", position=3) + # all bars are transient (leave=False): they're live UI while the export runs and are + # cleared when it ends - the log output is the permanent record + self.download_bar = self.manager.counter(total=num_images, desc="Images Downloaded", position=2, leave=False) + self.processed_bar = self.manager.counter(total=num_cards, desc="Cards Added to PDF", position=3, leave=False) self.download_bar.refresh() self.processed_bar.refresh() @@ -52,7 +229,17 @@ def set_state(self, state: str) -> None: self.status_bar.refresh() def __attrs_post_init__(self) -> None: - self.ask_questions() + if self.export_mode == "drive_thru_cards": + # DriveThruCards Premium Euro Poker requires 2.73" x 3.71" with bleed + self.card_width_in_inches = DTC_CARD_WIDTH_INCHES + self.card_height_in_inches = DTC_CARD_HEIGHT_INCHES + self.separate_faces = False + # Build one combined PDF per order using the actual exported slots. + # This is more robust than trusting `details.quantity`, which can + # under-report cards in some XMLs that still enumerate valid slots. + self.number_of_cards_per_file = max(1, len(self.order.fronts.slots())) + else: + self.ask_questions() self.configure_bars() self.generate_file_path() @@ -88,11 +275,7 @@ def ask_questions(self) -> None: ) def generate_file_path(self) -> None: - basename = os.path.basename(str(self.order.name)) - if not basename: - basename = "cards.xml" - file_name = os.path.splitext(basename)[0] - self.save_path = f"export/{file_name}/" + self.save_path = get_export_directory(self.order.name) + "/" os.makedirs(self.save_path, exist_ok=True) if self.separate_faces: for face in ["backs", "fronts"]: @@ -104,18 +287,63 @@ def generate_pdf(self) -> None: def add_image(self, image_path: str) -> None: self.pdf.add_page() - self.pdf.image(image_path, x=0, y=0, w=self.card_width_in_inches, h=self.card_height_in_inches) + if self.export_mode == "drive_thru_cards" and self.image_post_processing_config: + tmp_path = self.processed_image_paths.get(image_path) + if tmp_path is None: + with open(image_path, "rb") as f: + raw_image = f.read() + # post_process_image handles resizing to target_pixel_size (set in execute()) + # which ensures the correct DPI for the DTC card dimensions + processed_image, icc_profile_bytes = post_process_image( + raw_image=raw_image, config=self.image_post_processing_config + ) + # Save to a temporary file so fpdf embeds the JPEG data directly + # (passing BytesIO causes fpdf to re-encode with FlateDecode, bloating file size). + # Cached per source path so a repeated card (e.g. the shared cardback) is processed + # once and fpdf's per-path image cache embeds its data once per PDF. Temp files are + # cleaned up at the end of execute(). + ext = ".jpg" if self.image_post_processing_config.output_format == "JPEG" else ".png" + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp: + tmp_path = tmp.name + self.processed_image_paths[image_path] = tmp_path + save_processed_image( + processed_image, + file_path=tmp_path, + config=self.image_post_processing_config, + icc_profile_bytes=icc_profile_bytes, + ) + self.pdf.image( + tmp_path, + x=0, + y=0, + w=self.card_width_in_inches, + h=self.card_height_in_inches, + ) + else: + with open(image_path, "rb") as f: + image_bytes = f.read() + # Pass raw bytes so fpdf keys the cache by content hash rather than file path. + # This ensures refreshed image files are re-read when re-exporting PDFs. + self.pdf.image(image_bytes, x=0, y=0, w=self.card_width_in_inches, h=self.card_height_in_inches) - def save_file(self) -> None: + def save_file(self) -> str: extra = "" if self.separate_faces: extra = f"{self.current_face}/" - self.pdf.output(f"{self.save_path}{extra}{self.file_num}.pdf") + file_path = f"{self.save_path}{extra}{self.file_num}.pdf" + self.pdf.output(file_path) + self.saved_files.append(file_path) + return file_path def download_and_collect_images(self, post_processing_config: Optional[ImagePostProcessingConfig]) -> None: + download_config = None if self.export_mode == "drive_thru_cards" else post_processing_config with ThreadPoolExecutor(max_workers=THREADS) as pool: - self.order.fronts.download_images(pool, self.download_bar, post_processing_config) - self.order.backs.download_images(pool, self.download_bar, post_processing_config) + self.order.fronts.download_images(pool, self.download_bar, download_config) + self.order.backs.download_images(pool, self.download_bar, download_config) + + failed_images = self.order.get_failed_downloads() + if failed_images: + raise ImageDownloadError(failed_images) backs_by_slots = {} for card in self.order.backs.cards_by_id.values(): @@ -132,15 +360,49 @@ def download_and_collect_images(self, post_processing_config: Optional[ImagePost paths_by_slot[slot] = (str(backs_by_slots.get(slot, backs_by_slots[0])), str(fronts_by_slots[slot])) self.paths_by_slot = paths_by_slot - def execute(self, post_processing_config: Optional[ImagePostProcessingConfig]) -> None: - self.download_and_collect_images(post_processing_config=post_processing_config) - if self.separate_faces: - self.number_of_cards_per_file = 1 - self.export_separate_faces() - else: - self.export() + def execute(self, post_processing_config: Optional[ImagePostProcessingConfig]) -> list[str]: + if self.export_mode == "drive_thru_cards" and post_processing_config is not None: + # Calculate exact pixel dimensions for the target DPI at DTC card size (2.73" x 3.71") + post_processing_config.target_pixel_size = calculate_dtc_target_pixel_size(post_processing_config.max_dpi) + # Embed DPI metadata so PDF tools correctly interpret the image resolution + post_processing_config.embed_dpi_metadata = True + self.image_post_processing_config = post_processing_config + try: + self.download_and_collect_images(post_processing_config=post_processing_config) + try: + if self.separate_faces: + self.number_of_cards_per_file = 1 + self.export_separate_faces() + else: + self.export() + finally: + for tmp_path in self.processed_image_paths.values(): + if os.path.exists(tmp_path): + os.unlink(tmp_path) + self.processed_image_paths.clear() - logger.info(f"Finished exporting files! They should be accessible at {self.save_path}.") + if self.pdfx_config: + source_pdf_paths = list(self.saved_files) + total_files = len(source_pdf_paths) + for index, file_path in enumerate(source_pdf_paths, start=1): + logger.info(f"Converting PDF to PDF/X-1a ({index}/{total_files}): {file_path}") + pdfx_path = f"{os.path.splitext(file_path)[0]}_pdfx.pdf" + if convert_pdf_to_pdfx(file_path, pdfx_path, self.pdfx_config): + self.saved_files.append(pdfx_path) + logger.info(f"PDF/X-1a conversion succeeded: {pdfx_path}") + else: + logger.info(f"PDF/X-1a conversion failed for {file_path}. Using original PDF.") + + logger.info(f"Finished exporting files! They should be accessible at {self.save_path}.") + return self.saved_files + finally: + # The bars are transient UI - clear them and release the terminal rows so subsequent + # output (log lines, error summaries, later progress bars) flows naturally below the + # scrolled log output instead of around bars pinned to the bottom of the window. + self.status_bar.close(clear=True) + self.download_bar.close(clear=True) + self.processed_bar.close(clear=True) + self.manager.stop() def export(self) -> None: for slot in sorted(self.paths_by_slot.keys()): @@ -175,3 +437,4 @@ def export_separate_faces(self) -> None: self.save_file() if face_index == 1: self.file_num = self.file_num + 1 + self.processed_bar.update() diff --git a/desktop-tool/src/processing.py b/desktop-tool/src/processing.py index e5439c81c..f6d524dc9 100644 --- a/desktop-tool/src/processing.py +++ b/desktop-tool/src/processing.py @@ -1,30 +1,132 @@ import io +import os from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional from src.constants import DPI_HEIGHT_RATIO, ImageResizeMethods +from src.logging import logger if TYPE_CHECKING: from PIL import Image +# DriveThruCards physical card dimensions (Premium Euro Poker with bleed) +# DriveThruCards requires 2.73" x 3.71" which includes bleed area +DTC_CARD_WIDTH_INCHES = 2.73 +DTC_CARD_HEIGHT_INCHES = 3.71 + + +def calculate_dtc_target_pixel_size(target_dpi: int) -> tuple[int, int]: + """ + Calculate the target pixel dimensions for DriveThruCards at the specified DPI. + Card size is 2.73" x 3.71" (Premium Euro Poker with bleed). + """ + width = max(1, round(DTC_CARD_WIDTH_INCHES * target_dpi)) + height = max(1, round(DTC_CARD_HEIGHT_INCHES * target_dpi)) + return (width, height) + @dataclass class ImagePostProcessingConfig: max_dpi: int downscale_alg: ImageResizeMethods - # jpeg: bool + output_format: Optional[str] = None + output_extension: Optional[str] = None + convert_to_cmyk: bool = False + icc_profile_path: Optional[str] = None + output_directory: Optional[str] = None + jpeg_quality: int = 95 + target_pixel_size: Optional[tuple[int, int]] = None + embed_dpi_metadata: bool = False + + +def get_post_processed_path(file_path: str, config: ImagePostProcessingConfig) -> str: + directory = config.output_directory or os.path.dirname(file_path) + base_name = os.path.splitext(os.path.basename(file_path))[0] + extension = config.output_extension or os.path.splitext(file_path)[1] + return os.path.join(directory, f"{base_name}{extension}") + + +def _apply_color_processing(img: "Image", config: ImagePostProcessingConfig) -> tuple["Image", Optional[bytes]]: + icc_profile_bytes = None + if config.convert_to_cmyk: + if img.mode in ("RGBA", "LA"): + img = img.convert("RGB") + elif img.mode not in ("RGB", "CMYK"): + img = img.convert("RGB") + if config.icc_profile_path: + try: + from PIL import ImageCms + srgb = ImageCms.createProfile("sRGB") + cmyk_profile = ImageCms.getOpenProfile(config.icc_profile_path) + img = ImageCms.profileToProfile(img, srgb, cmyk_profile, outputMode="CMYK") + icc_profile_bytes = cmyk_profile.tobytes() + except Exception as exc: + logger.warning(f"Failed to apply ICC profile ({config.icc_profile_path}): {exc}") + img = img.convert("CMYK") + else: + img = img.convert("CMYK") + elif config.output_format and config.output_format.upper() == "JPEG": + if img.mode in ("RGBA", "LA"): + img = img.convert("RGB") + return img, icc_profile_bytes -def post_process_image(raw_image: bytes, config: ImagePostProcessingConfig) -> "Image": + +def post_process_image(raw_image: bytes, config: ImagePostProcessingConfig) -> tuple["Image", Optional[bytes]]: from PIL import Image img = Image.open(io.BytesIO(raw_image)) # downscale the image to `max_dpi` - img_dpi = 10 * round(int(img.height) * DPI_HEIGHT_RATIO / 10) - if img_dpi > config.max_dpi: - new_height = round((config.max_dpi / img_dpi) * img.height) - new_width = round((config.max_dpi / img_dpi) * img.width) - img = img.resize((new_width, new_height), config.downscale_alg.value) + if config.target_pixel_size: + target_width, target_height = config.target_pixel_size + if img.width != target_width or img.height != target_height: + # For DTC, force exact pixel size to guarantee 300 DPI at 2.73" x 3.71". + img = img.resize((target_width, target_height), config.downscale_alg.value) + else: + img_dpi = 10 * round(int(img.height) * DPI_HEIGHT_RATIO / 10) + if img_dpi > config.max_dpi: + new_height = round((config.max_dpi / img_dpi) * img.height) + new_width = round((config.max_dpi / img_dpi) * img.width) + img = img.resize((new_width, new_height), config.downscale_alg.value) + + img, icc_profile_bytes = _apply_color_processing(img, config) + return img, icc_profile_bytes + + +def save_processed_image( + img: "Image", + file_path: str, + config: ImagePostProcessingConfig, + icc_profile_bytes: Optional[bytes] = None, +) -> None: + # Remove XMP data if it's present in the image info to avoid "XMP data is too long" error. + # JPEG format has a 64KB limit for XMP metadata in a single APP1 segment. + if "xmp" in img.info: + img.info.pop("xmp") + + img.save(file_path, **_build_save_kwargs(config=config, icc_profile_bytes=icc_profile_bytes)) + - return img +def _build_save_kwargs( + config: ImagePostProcessingConfig, + icc_profile_bytes: Optional[bytes], +) -> dict[str, Any]: + save_kwargs: dict[str, Any] = {} + if config.output_format: + save_kwargs["format"] = config.output_format + if config.output_format and config.output_format.upper() == "JPEG": + save_kwargs["quality"] = config.jpeg_quality + save_kwargs["subsampling"] = 0 + save_kwargs["optimize"] = True + if icc_profile_bytes: + save_kwargs["icc_profile"] = icc_profile_bytes + # Embed DPI metadata to ensure PDF tools correctly interpret the image resolution. + # This is critical for DriveThruCards where the target DPI must be 300. + if config.embed_dpi_metadata and config.target_pixel_size: + # Calculate DPI from target pixel size and DTC card dimensions + target_width, target_height = config.target_pixel_size + dpi_x = round(target_width / DTC_CARD_WIDTH_INCHES) + dpi_y = round(target_height / DTC_CARD_HEIGHT_INCHES) + save_kwargs["dpi"] = (dpi_x, dpi_y) + return save_kwargs diff --git a/desktop-tool/src/web_server.py b/desktop-tool/src/web_server.py index 83d20511d..44c500b7c 100644 --- a/desktop-tool/src/web_server.py +++ b/desktop-tool/src/web_server.py @@ -17,7 +17,7 @@ def do_GET(self) -> None: self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() - self.wfile.write(Path(__file__).joinpath("../..").joinpath(POST_LAUNCH_HTML_FILENAME).resolve().read_bytes()) + self.wfile.write(self.server.html_path.read_bytes()) # type: ignore[attr-defined] def log_request(self, code: Union[int, str] = "-", size: Union[int, str] = "-") -> None: # Silence the request log. @@ -25,8 +25,9 @@ def log_request(self, code: Union[int, str] = "-", size: Union[int, str] = "-") class WebServer: - def __init__(self) -> None: + def __init__(self, html_filename: str = POST_LAUNCH_HTML_FILENAME) -> None: self._server = server.ThreadingHTTPServer(("", 0), _Handler) + self._server.html_path = Path(__file__).joinpath("../..").joinpath(html_filename).resolve() # type: ignore[attr-defined] self._thread = threading.Thread(target=self._server.serve_forever) self._thread.start() logger.info(f"Web server started on {self.server_url()}") diff --git a/desktop-tool/src/webdrivers.py b/desktop-tool/src/webdrivers.py index 179d11a9c..58573c56c 100644 --- a/desktop-tool/src/webdrivers.py +++ b/desktop-tool/src/webdrivers.py @@ -1,15 +1,20 @@ +import re +import subprocess import sys -from typing import Optional +from typing import TYPE_CHECKING, Any, Optional -from selenium.webdriver import Chrome, Edge, Firefox -from selenium.webdriver.chrome.options import Options as ChromeOptions -from selenium.webdriver.chromium.options import ChromiumOptions -from selenium.webdriver.chromium.webdriver import ChromiumDriver -from selenium.webdriver.edge.options import Options as EdgeOptions -from selenium.webdriver.firefox.options import Options as FirefoxOptions +if TYPE_CHECKING: + from selenium.webdriver.chrome.webdriver import WebDriver as Chrome + from selenium.webdriver.chromium.webdriver import ChromiumDriver + from selenium.webdriver.firefox.webdriver import WebDriver as Firefox +else: + Chrome = ChromiumDriver = Firefox = Any def get_chrome_driver(headless: bool = False, binary_location: Optional[str] = None) -> Chrome: + from selenium.webdriver.chrome.options import Options as ChromeOptions + from selenium.webdriver.chrome.webdriver import WebDriver as ChromeDriver + options = ChromeOptions() options.add_argument("--no-sandbox") options.add_argument("--log-level=3") @@ -20,12 +25,15 @@ def get_chrome_driver(headless: bool = False, binary_location: Optional[str] = N options.add_experimental_option("detach", True) if binary_location is not None: options.binary_location = binary_location - driver = Chrome(options=options) + driver = ChromeDriver(options=options) driver.set_network_conditions(offline=False, latency=5, throughput=5 * 125000) return driver def get_brave_driver(headless: bool = False, binary_location: Optional[str] = None) -> Chrome: + from selenium.webdriver.chrome.options import Options as ChromeOptions + from selenium.webdriver.chrome.webdriver import WebDriver as ChromeDriver + options = ChromeOptions() options.add_argument("--no-sandbox") options.add_argument("--log-level=3") @@ -36,26 +44,18 @@ def get_brave_driver(headless: bool = False, binary_location: Optional[str] = No options.add_experimental_option("detach", True) # the binary location for brave must be manually specified (otherwise chrome will open instead) - if binary_location is not None: - options.binary_location = binary_location - else: - default_binary_locations = { - "linux": "/usr/bin/brave-browser", - "darwin": "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", - "win32": "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe", - } - if sys.platform not in default_binary_locations.keys(): - raise KeyError( - f"Cannot determine the default Brave binary location for the operating system {sys.platform}!" - ) - options.binary_location = default_binary_locations[sys.platform] + options.binary_location = binary_location or get_default_brave_binary_location() - driver = Chrome(options=options) + driver = ChromeDriver(options=options) driver.set_network_conditions(offline=False, latency=5, throughput=5 * 125000) return driver def get_edge_driver(headless: bool = False, binary_location: Optional[str] = None) -> ChromiumDriver: + from selenium.webdriver.chromium.options import ChromiumOptions + from selenium.webdriver.edge.options import Options as EdgeOptions + from selenium.webdriver.edge.webdriver import WebDriver as Edge + options: ChromiumOptions = EdgeOptions() options.add_argument("--no-sandbox") options.add_argument("--log-level=3") @@ -73,11 +73,105 @@ def get_edge_driver(headless: bool = False, binary_location: Optional[str] = Non # note: firefox is not currently supported def get_firefox_driver(headless: bool = False, binary_location: Optional[str] = None) -> Firefox: + from selenium.webdriver.firefox.options import Options as FirefoxOptions + from selenium.webdriver.firefox.webdriver import WebDriver as FirefoxDriver + options = FirefoxOptions() options.add_argument("--log-level=3") if headless: options.add_argument("--headless") if binary_location is not None: options.binary_location = binary_location - driver = Firefox(options=options) + driver = FirefoxDriver(options=options) return driver + + +def get_default_brave_binary_location() -> str: + default_binary_locations = { + "linux": "/usr/bin/brave-browser", + "darwin": "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "win32": "C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe", + } + if sys.platform not in default_binary_locations.keys(): + raise KeyError(f"Cannot determine the default Brave binary location for the operating system {sys.platform}!") + return default_binary_locations[sys.platform] + + +def _detect_chrome_version(binary_location: Optional[str] = None) -> Optional[int]: + """ + Detect the selected Chromium browser version. + Returns the major version number (e.g., 144) or None if detection fails. + """ + try: + if sys.platform == "darwin": + result = subprocess.run( + [binary_location or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + elif sys.platform == "win32": + registry_key = ( + r"HKEY_CURRENT_USER\Software\BraveSoftware\Brave-Browser\BLBeacon" + if binary_location and "brave" in binary_location.lower() + else r"HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" + ) + result = subprocess.run( + ["reg", "query", registry_key, "/v", "version"], + capture_output=True, + text=True, + timeout=5, + ) + else: + result = subprocess.run( + [binary_location or "google-chrome", "--version"], + capture_output=True, + text=True, + timeout=5, + ) + + # Extract version number from output (e.g., "Google Chrome 144.0.7559.110") + match = re.search(r"(\d+)\.\d+\.\d+\.\d+", result.stdout) + if match: + return int(match.group(1)) + except Exception: + pass + return None + + +def get_undetected_chrome_driver( + headless: bool = False, + binary_location: Optional[str] = None, + user_data_dir: Optional[str] = None, + profile_directory: Optional[str] = None, +) -> Chrome: + """ + Create a Chrome driver using undetected-chromedriver, for sites (DriveThruCards) whose bot detection + blocks standard Selenium. Only used when targeting DriveThruCards. + """ + import undetected_chromedriver as uc + + if binary_location is None: + binary_location = uc.find_chrome_executable() + if binary_location is None: + raise FileNotFoundError( + "Google Chrome was not found. Install Chrome, choose Brave with --browser brave, " + "or specify a Chromium executable with --binary-location." + ) + + options = uc.ChromeOptions() + options.add_argument("--no-sandbox") + options.add_argument("--log-level=3") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--disable-blink-features=AutomationControlled") + if headless: + options.add_argument("--headless=new") + options.binary_location = binary_location + if user_data_dir is not None: + options.add_argument(f"--user-data-dir={user_data_dir}") + if profile_directory is not None: + options.add_argument(f"--profile-directory={profile_directory}") + + # undetected-chromedriver handles stealth automatically. + # Detect the Chrome version ourselves since auto-detection can fail. + return uc.Chrome(options=options, version_main=_detect_chrome_version(binary_location)) diff --git a/desktop-tool/tests/test_desktop_tool.py b/desktop-tool/tests/test_desktop_tool.py index 3e34f73e5..ba0e3750b 100644 --- a/desktop-tool/tests/test_desktop_tool.py +++ b/desktop-tool/tests/test_desktop_tool.py @@ -1,23 +1,38 @@ +import gc +import hashlib +import inspect +import logging import os +import re +import subprocess +import sys import textwrap import time from concurrent.futures import ThreadPoolExecutor +from contextlib import nullcontext +from io import BytesIO from itertools import groupby +from pathlib import Path from queue import Queue +from types import SimpleNamespace from typing import Callable, Generator from xml.etree import ElementTree +import autofill as autofill_cli import pytest +from click.testing import CliRunner from enlighten import Counter +from PIL import Image from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWait import src import src.constants as constants +import src.webdrivers as webdrivers from src.constants import OrderFulfilmentMethod, SourceType from src.driver import AutofillDriver -from src.exc import ValidationException +from src.exc import ImageDownloadError, ValidationException from src.formatting import text_to_set from src.io import get_google_drive_file_name, remove_directories, remove_files from src.order import ( @@ -27,9 +42,20 @@ Details, aggregate_and_split_orders, ) -from src.pdf_maker import PdfExporter +from src.pdf_maker import ( + PdfExporter, + PdfXConversionConfig, + convert_pdf_to_pdfx, + get_ghostscript_path, + get_ghostscript_version, +) from src.processing import ImagePostProcessingConfig +requires_google_drive_credentials = pytest.mark.skipif( + not os.path.isfile(os.path.join(os.path.dirname(__file__), "..", "client_secrets.json")), + reason="Google Drive API credentials (client_secrets.json) are not available", +) + DEFAULT_POST_PROCESSING = ImagePostProcessingConfig(max_dpi=800, downscale_alg=constants.ImageResizeMethods.LANCZOS) @@ -70,6 +96,1007 @@ def assert_file_size(file_path: str, size: int) -> None: assert os.stat(file_path).st_size == size, f"File size {os.stat(file_path).st_size} does not match {size}" +def count_pdf_pages(file_path: str) -> int: + with open(file_path, "rb") as pdf_file: + return len(re.findall(rb"/Type\s*/Page\b", pdf_file.read())) + + +# endregion + +# region Ghostscript + + +def test_get_ghostscript_version_reads_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + def __init__(self) -> None: + self.stdout = "10.02.1\n" + + def fake_run(*_args, **_kwargs): + return Result() + + monkeypatch.setattr("src.pdf_maker.subprocess.run", fake_run) + assert get_ghostscript_version("gs") == "10.02.1" + + +def test_ensure_ghostscript_available_prompts_until_found(monkeypatch: pytest.MonkeyPatch, input_enter) -> None: + paths = [None, "/usr/local/bin/gs"] + called = {"version": 0} + + def fake_get_path(): + return paths.pop(0) + + def fake_get_version(_path: str) -> str: + called["version"] += 1 + return "10.0.0" + + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", fake_get_path) + monkeypatch.setattr(autofill_cli, "get_ghostscript_version", fake_get_version) + monkeypatch.setattr(autofill_cli.click, "confirm", lambda *_args, **_kwargs: False) + + assert autofill_cli.ensure_ghostscript_available() == "/usr/local/bin/gs" + assert called["version"] == 1 + + +def test_ensure_ghostscript_available_installs_official_release_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + paths = [None, "C:\\Program Files\\gs\\gswin64c.exe"] + install_calls = [] + installer = b"official Ghostscript installer" + asset_name = "gs10071w64.exe" + download_url = f"https://example.test/{asset_name}" + + monkeypatch.setattr(autofill_cli.sys, "platform", "win32", raising=False) + monkeypatch.setattr(autofill_cli.sys, "maxsize", 2**63 - 1) + monkeypatch.setitem( + autofill_cli.GHOSTSCRIPT_WINDOWS_INSTALLERS, + "w64.exe", + (download_url, hashlib.sha256(installer).hexdigest()), + ) + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", lambda: paths.pop(0)) + monkeypatch.setattr(autofill_cli, "get_ghostscript_version", lambda _path: "10.0.0") + monkeypatch.setattr(autofill_cli.click, "confirm", lambda *_args, **_kwargs: True) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "\n") + + def fake_urlopen(request, timeout): + assert request.full_url == download_url + return BytesIO(installer) + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + + def fake_run(cmd, check=False, env=None): + assert Path(env["MPC_AUTOFILL_GS_INSTALLER"]).read_bytes() == installer + install_calls.append(cmd) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(autofill_cli.subprocess, "run", fake_run) + + resolved = autofill_cli.ensure_ghostscript_available() + + assert resolved == "C:\\Program Files\\gs\\gswin64c.exe" + assert len(install_calls) == 1 + assert install_calls[0][0] == "powershell.exe" + assert "Start-Process" in install_calls[0][-1] + + +def test_install_ghostscript_windows_rejects_wrong_checksum(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(autofill_cli.sys, "maxsize", 2**63 - 1) + monkeypatch.setitem( + autofill_cli.GHOSTSCRIPT_WINDOWS_INSTALLERS, + "w64.exe", + ("https://example.test/gs.exe", "0" * 64), + ) + monkeypatch.setattr("urllib.request.urlopen", lambda *_args, **_kwargs: BytesIO(b"modified")) + monkeypatch.setattr( + autofill_cli.subprocess, + "run", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unverified installer must not run")), + ) + + with pytest.raises(RuntimeError, match="checksum"): + autofill_cli._install_ghostscript_windows() + + +@pytest.mark.skipif( + os.environ.get("MPC_AUTOFILL_RELEASE_CHECKS") != "1", + reason="live release dependency check", +) +@pytest.mark.parametrize( + "download_url", + [installer[0] for installer in autofill_cli.GHOSTSCRIPT_WINDOWS_INSTALLERS.values()], +) +def test_pinned_ghostscript_installers_are_available(download_url: str) -> None: + from urllib.request import Request, urlopen + + request = Request(download_url, method="HEAD", headers={"User-Agent": "mpc-autofill"}) + with urlopen(request, timeout=30) as response: + assert response.status == 200 + + +def test_get_ghostscript_path_finds_standard_windows_install(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + executable = tmp_path / "gs" / "gs10.07.1" / "bin" / "gswin64c.exe" + executable.parent.mkdir(parents=True) + executable.touch() + monkeypatch.setattr("src.pdf_maker.shutil.which", lambda _name: None) + monkeypatch.setattr("src.pdf_maker.sys.platform", "win32") + monkeypatch.setenv("ProgramFiles", str(tmp_path)) + + assert get_ghostscript_path() == str(executable) + + +def test_ensure_ghostscript_available_installs_with_apt_on_linux( + monkeypatch: pytest.MonkeyPatch, +) -> None: + paths = [None, "/usr/bin/gs"] + install_calls = [] + + monkeypatch.setattr(autofill_cli.sys, "platform", "linux", raising=False) + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", lambda: paths.pop(0)) + monkeypatch.setattr(autofill_cli, "get_ghostscript_version", lambda _path: "10.0.0") + monkeypatch.setattr(autofill_cli.click, "confirm", lambda *_args, **_kwargs: True) + monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "\n") + monkeypatch.setattr( + autofill_cli.shutil, + "which", + lambda name: "/usr/bin/" + name if name in {"sudo", "apt"} else None, + ) + + def fake_run(cmd, check=False): + install_calls.append(cmd) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(autofill_cli.subprocess, "run", fake_run) + + resolved = autofill_cli.ensure_ghostscript_available() + + assert resolved == "/usr/bin/gs" + assert install_calls == [["sudo", "apt", "install", "-y", "ghostscript"]] + + +def test_ensure_ghostscript_available_asks_permission_before_installing(monkeypatch: pytest.MonkeyPatch) -> None: + paths = [None, "/usr/local/bin/gs"] + asked = {"message": None, "default": None} + + def fake_confirm(message: str, default: bool = True) -> bool: + asked["message"] = message + asked["default"] = default + return True + + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", lambda: paths.pop(0)) + monkeypatch.setattr(autofill_cli, "get_ghostscript_version", lambda _path: "10.0.0") + monkeypatch.setattr(autofill_cli.click, "confirm", fake_confirm) + monkeypatch.setattr(autofill_cli, "_install_ghostscript", lambda: True) + + assert autofill_cli.ensure_ghostscript_available() == "/usr/local/bin/gs" + assert "install Ghostscript now" in asked["message"] + assert asked["default"] is True + + +def test_ensure_ghostscript_available_does_not_prompt_when_already_installed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", lambda: "/usr/local/bin/gs") + monkeypatch.setattr(autofill_cli, "get_ghostscript_version", lambda _path: "10.0.0") + monkeypatch.setattr( + autofill_cli.click, + "confirm", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not prompt")), + ) + + assert autofill_cli.ensure_ghostscript_available() == "/usr/local/bin/gs" + + +def test_maybe_reuse_existing_pdfs_detects_stale_pdfx_even_when_another_pdf_is_newer( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + order_name = "example.xml" + export_dir = tmp_path / "export" / "example" + export_dir.mkdir(parents=True) + cards_dir = tmp_path / "cards" + cards_dir.mkdir() + + # The PDF/X output is older than the card images, but a plain PDF is newer than both. + (export_dir / "1_pdfx.pdf").write_bytes(b"pdfx") + os.utime(export_dir / "1_pdfx.pdf", (1_000, 1_000)) + (cards_dir / "card.jpg").write_bytes(b"jpg") + os.utime(cards_dir / "card.jpg", (2_000, 2_000)) + (export_dir / "1.pdf").write_bytes(b"pdf") + os.utime(export_dir / "1.pdf", (3_000, 3_000)) + + prompts = {"count": 0} + + def fake_confirm(*_args, **_kwargs) -> bool: + prompts["count"] += 1 + return True # recreate the PDF export + + monkeypatch.setattr(autofill_cli.click, "confirm", fake_confirm) + + cwd_before = os.getcwd() + os.chdir(tmp_path) + try: + assert ( + autofill_cli.maybe_reuse_existing_pdfs( + order_name=order_name, + skip_pdf_if_exists=True, + cards_directory=str(cards_dir), + require_pdfx=True, + ) + is None + ) + finally: + os.chdir(cwd_before) + + assert prompts["count"] == 1 + + +def test_maybe_reuse_existing_pdfs_returns_none_when_skip_disabled(tmp_path) -> None: + order_name = "example.xml" + export_dir = tmp_path / "export" / "example" + export_dir.mkdir(parents=True) + pdf_path = export_dir / "1.pdf" + pdf_path.write_bytes(b"pdf") + + cwd_before = os.getcwd() + os.chdir(tmp_path) + try: + assert ( + autofill_cli.maybe_reuse_existing_pdfs( + order_name=order_name, + skip_pdf_if_exists=False, + cards_directory=str(tmp_path / "cards"), + ) + is None + ) + finally: + os.chdir(cwd_before) + + +def test_maybe_reuse_existing_pdfs_reuses_existing_pdf_when_fresh(tmp_path) -> None: + order_name = "example.xml" + export_dir = tmp_path / "export" / "example" + cards_dir = tmp_path / "cards" + export_dir.mkdir(parents=True) + cards_dir.mkdir() + + pdf_path = export_dir / "1.pdf" + pdf_path.write_bytes(b"pdf") + card_path = cards_dir / "card.png" + card_path.write_bytes(b"card") + + now = time.time() + os.utime(card_path, (now - 20, now - 20)) + os.utime(pdf_path, (now - 10, now - 10)) + + cwd_before = os.getcwd() + os.chdir(tmp_path) + try: + reused = autofill_cli.maybe_reuse_existing_pdfs( + order_name=order_name, + skip_pdf_if_exists=True, + cards_directory=str(cards_dir), + ) + assert reused == [str(pdf_path.relative_to(tmp_path))] + finally: + os.chdir(cwd_before) + + +def test_maybe_reuse_existing_pdfs_recreates_when_cards_newer_and_user_confirms( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + order_name = "example.xml" + export_dir = tmp_path / "export" / "example" + cards_dir = tmp_path / "cards" + export_dir.mkdir(parents=True) + cards_dir.mkdir() + + pdf_path = export_dir / "1.pdf" + pdf_path.write_bytes(b"pdf") + card_path = cards_dir / "card.png" + card_path.write_bytes(b"card") + + now = time.time() + os.utime(pdf_path, (now - 20, now - 20)) + os.utime(card_path, (now - 10, now - 10)) + monkeypatch.setattr("autofill.click.confirm", lambda *_args, **_kwargs: True) + + cwd_before = os.getcwd() + os.chdir(tmp_path) + try: + assert ( + autofill_cli.maybe_reuse_existing_pdfs( + order_name=order_name, + skip_pdf_if_exists=True, + cards_directory=str(cards_dir), + ) + is None + ) + finally: + os.chdir(cwd_before) + + +def test_maybe_reuse_existing_pdfs_keeps_existing_when_cards_newer_and_user_declines( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + order_name = "example.xml" + export_dir = tmp_path / "export" / "example" + cards_dir = tmp_path / "cards" + export_dir.mkdir(parents=True) + cards_dir.mkdir() + + pdf_path = export_dir / "1.pdf" + pdf_path.write_bytes(b"pdf") + card_path = cards_dir / "card.png" + card_path.write_bytes(b"card") + + now = time.time() + os.utime(pdf_path, (now - 20, now - 20)) + os.utime(card_path, (now - 10, now - 10)) + monkeypatch.setattr("autofill.click.confirm", lambda *_args, **_kwargs: False) + + cwd_before = os.getcwd() + os.chdir(tmp_path) + try: + reused = autofill_cli.maybe_reuse_existing_pdfs( + order_name=order_name, + skip_pdf_if_exists=True, + cards_directory=str(cards_dir), + ) + assert reused == [str(pdf_path.relative_to(tmp_path))] + finally: + os.chdir(cwd_before) + + +def test_maybe_reuse_existing_pdfs_requires_pdfx_if_requested(tmp_path) -> None: + order_name = "example.xml" + export_dir = tmp_path / "export" / "example" + export_dir.mkdir(parents=True) + (export_dir / "1.pdf").write_bytes(b"pdf") + + cwd_before = os.getcwd() + os.chdir(tmp_path) + try: + assert ( + autofill_cli.maybe_reuse_existing_pdfs( + order_name=order_name, + skip_pdf_if_exists=True, + cards_directory=str(tmp_path / "cards"), + require_pdfx=True, + ) + is None + ) + finally: + os.chdir(cwd_before) + + +def test_get_undetected_chrome_driver_applies_user_profile_options(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {"options": None, "version_main": None} + + def fake_chrome(*, options, version_main): + captured["options"] = options + captured["version_main"] = version_main + return object() + + monkeypatch.setattr(webdrivers, "_detect_chrome_version", lambda _: 120) + monkeypatch.setattr("undetected_chromedriver.Chrome", fake_chrome) + + webdrivers.get_undetected_chrome_driver( + binary_location="/tmp/chrome", + user_data_dir="/tmp/chrome-data", + profile_directory="Profile 7", + ) + + assert captured["options"].binary_location == "/tmp/chrome" + assert "--user-data-dir=/tmp/chrome-data" in captured["options"].arguments + assert "--profile-directory=Profile 7" in captured["options"].arguments + assert captured["version_main"] == 120 + + +def test_detect_chrome_version_uses_selected_brave_browser(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + + def fake_run(args, **kwargs): + captured["args"] = args + return SimpleNamespace(stdout="version REG_SZ 150.1.92.143") + + monkeypatch.setattr(webdrivers.sys, "platform", "win32") + monkeypatch.setattr(webdrivers.subprocess, "run", fake_run) + + assert webdrivers._detect_chrome_version(r"C:\Program Files\BraveSoftware\brave.exe") == 150 + assert r"BraveSoftware\Brave-Browser\BLBeacon" in captured["args"][2] + + +def test_get_undetected_chrome_driver_reports_missing_chrome(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("undetected_chromedriver.find_chrome_executable", lambda: None) + + with pytest.raises(FileNotFoundError, match="Google Chrome was not found.*--browser brave"): + webdrivers.get_undetected_chrome_driver() + + +@pytest.mark.parametrize("browser", constants.Browsers) +def test_standard_driver_factories_accept_only_upstream_kwargs(browser: constants.Browsers) -> None: + # Regression test: passing DTC-only kwargs (user_data_dir etc.) to the standard factories + # used for MakePlayingCards-family sites must fail loudly, proving they were never added there. + factory_parameters = inspect.signature(browser.value).parameters + assert set(factory_parameters.keys()) == {"headless", "binary_location"} + + +def test_cli_help_includes_download_images_only_option() -> None: + result = CliRunner().invoke(autofill_cli.main, ["--help"]) + assert result.exit_code == 0 + assert "--download-images-only" in result.output + + +def test_cli_help_includes_global_log_level_option() -> None: + result = CliRunner().invoke(autofill_cli.main, ["--help"]) + assert result.exit_code == 0 + assert "--log-level" in result.output + + +def test_cli_help_documents_new_flags() -> None: + result = CliRunner().invoke(autofill_cli.main, ["--help"]) + assert result.exit_code == 0 + assert "--skip-pdf-if-exists" in result.output + assert "--download-images-only" in result.output + assert "--browser-profile-path" in result.output + assert "--browser-profile-name" in result.output + assert "--skip-dtc-instructions" in result.output + assert "detailed Selenium step-by-step logs" in result.output + + +def test_configure_tls_uses_bundled_certificates_without_overwriting_user_value( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + monkeypatch.delenv("SSL_CERT_FILE", raising=False) + assert autofill_cli.configure_tls() == autofill_cli.certifi.where() + assert os.path.isfile(os.environ["SSL_CERT_FILE"]) + + custom_bundle = tmp_path / "company-ca.pem" + custom_bundle.touch() + monkeypatch.setenv("SSL_CERT_FILE", str(custom_bundle)) + assert autofill_cli.configure_tls() == str(custom_bundle) + + +def test_startup_defers_heavy_runtime_imports() -> None: + result = subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys, autofill; " + "heavy=('undetected_chromedriver','fpdf','selenium','googleapiclient','wakepy'); " + "assert not [name for name in heavy if any(m == name or m.startswith(name + '.') for m in sys.modules)]" + ), + ], + cwd=Path(autofill_cli.__file__).parent, + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + +def test_should_run_interactive_onboarding_only_for_no_argument_tty(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(autofill_cli.sys, "argv", ["autofill.py"]) + monkeypatch.setattr(autofill_cli.sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(autofill_cli.sys.stdout, "isatty", lambda: True) + assert autofill_cli.should_run_interactive_onboarding() + + monkeypatch.setattr(autofill_cli.sys, "argv", ["autofill.py", "--site", "MakePlayingCards"]) + assert not autofill_cli.should_run_interactive_onboarding() + + +@pytest.mark.parametrize( + ("site", "responses", "expected", "prompt_count"), + [ + ( + "MakePlayingCards", + ["chrome", "MakePlayingCards", False, True], + ("chrome", "MakePlayingCards", False, True), + 4, + ), + ("DriveThruCards", ["chrome", "DriveThruCards"], ("chrome", "DriveThruCards", True, False), 2), + ], +) +def test_interactive_onboarding_uses_picker_and_skips_dtc_only_questions( + monkeypatch: pytest.MonkeyPatch, site: str, responses: list[object], expected: tuple, prompt_count: int +) -> None: + prompts = [] + answers = iter(responses) + + class Prompt: + def execute(self): + return next(answers) + + def fake_rawlist(**kwargs): + prompts.append(kwargs) + return Prompt() + + monkeypatch.setattr(autofill_cli.inquirer, "rawlist", fake_rawlist) + + assert autofill_cli.run_interactive_onboarding() == expected + assert len(prompts) == prompt_count + assert prompts[1]["choices"][-1] == "DriveThruCards" + + +def test_dtc_overridden_explicit_flags_are_explained_in_logs(tmp_path, caplog, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("src.logging.configure_loggers", lambda **_kwargs: None) + monkeypatch.setattr("wakepy.keepawake", lambda **_kwargs: nullcontext()) + + with caplog.at_level(logging.INFO, logger="src.logging"): + # no XML files in tmp_path, so the run exits at the "No XML files found" input() prompt + result = CliRunner().invoke( + autofill_cli.main, + [ + "-d", + str(tmp_path), + "--site", + "DriveThruCards", + "--image-post-processing", + "--no-auto-save", + "--download-images-only", + ], + input="\n", + ) + + assert result.exit_code == 0 + assert "Ignoring --image-post-processing" in caplog.text + assert "Ignoring --no-auto-save" in caplog.text + + +def test_cli_site_choices_list_drivethrucards_last() -> None: + result = CliRunner().invoke(autofill_cli.main, ["--help"]) + assert result.exit_code == 0 + site_line = next(line for line in result.output.splitlines() if line.strip().startswith("--site [")) + assert site_line.endswith("DriveThruCards]") + + +def test_main_drive_thru_cards_exportpdf_generates_pdfs_without_browser_automation( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + icc_path = tmp_path / "test.icc" + icc_path.write_bytes(b"icc") + browser_path = tmp_path / "chrome.exe" + browser_path.touch() + + calls = {"pdf": [], "wait": 0, "driver": 0} + + monkeypatch.setattr("src.logging.configure_loggers", lambda **_kwargs: None) + monkeypatch.setattr("wakepy.keepawake", lambda **_kwargs: nullcontext()) + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", lambda: "/opt/homebrew/bin/gs") + monkeypatch.setattr(autofill_cli, "ensure_ghostscript_available", lambda **_kwargs: "/opt/homebrew/bin/gs") + monkeypatch.setattr("src.icc.find_or_download_dtc_icc_profile", lambda: str(icc_path)) + monkeypatch.setattr( + CardOrder, + "from_xmls_in_folder", + lambda working_directory: [SimpleNamespace(name="test_local")], + ) + + def fake_get_dtc_pdf_paths_for_order(**kwargs): + calls["pdf"].append(kwargs["order"].name) + return ["export/test_local/1.pdf", "export/test_local/1_pdfx.pdf"] + + monkeypatch.setattr(autofill_cli, "get_dtc_pdf_paths_for_order", fake_get_dtc_pdf_paths_for_order) + monkeypatch.setattr(autofill_cli, "wait_for_user_to_complete_order", lambda: calls.__setitem__("wait", 1)) + + class ShouldNotInstantiateDriver: + def __init__(self, *args, **kwargs) -> None: + calls["driver"] += 1 + raise AssertionError("DriveThruCards browser automation should not run during --exportpdf") + + monkeypatch.setattr("src.driver.AutofillDriver", ShouldNotInstantiateDriver) + + result = CliRunner().invoke( + autofill_cli.main, + [ + "--directory", + str(tmp_path), + "--site", + constants.TargetSites.DriveThruCards.name, + "--exportpdf", + "--browser", + constants.Browsers.chrome.name, + "--binary-location", + str(browser_path), + ], + ) + + assert result.exit_code == 0 + assert calls["pdf"] == ["test_local"] + assert calls["wait"] == 0 + assert calls["driver"] == 0 + + +@pytest.mark.parametrize( + ("skip_instructions", "expected_starting_url", "expected_server_count"), + [ + (False, "http://localhost:1234/", 1), + (True, constants.TargetSites.DriveThruCards.value.starting_url, 0), + ], +) +def test_main_drive_thru_cards_keeps_driver_alive_until_user_handoff( + monkeypatch: pytest.MonkeyPatch, + tmp_path, + skip_instructions: bool, + expected_starting_url: str, + expected_server_count: int, +) -> None: + icc_path = tmp_path / "test.icc" + icc_path.write_bytes(b"icc") + + state = {"finalized": 0, "executed": 0, "wait_seen": None, "servers": 0, "starting_url": None} + + monkeypatch.setattr("src.logging.configure_loggers", lambda **_kwargs: None) + monkeypatch.setattr("wakepy.keepawake", lambda **_kwargs: nullcontext()) + monkeypatch.setattr(autofill_cli, "get_ghostscript_path", lambda: "/opt/homebrew/bin/gs") + monkeypatch.setattr(autofill_cli, "ensure_ghostscript_available", lambda **_kwargs: "/opt/homebrew/bin/gs") + monkeypatch.setattr("src.icc.find_or_download_dtc_icc_profile", lambda: str(icc_path)) + monkeypatch.setattr( + CardOrder, + "from_xmls_in_folder", + lambda working_directory: [SimpleNamespace(name="test_local")], + ) + monkeypatch.setattr( + autofill_cli, + "get_dtc_pdf_paths_for_order", + lambda **_kwargs: ["export/test_local/1.pdf", "export/test_local/1_pdfx.pdf"], + ) + + class FakeWebServer: + def __init__(self, html_filename: str) -> None: + assert html_filename == constants.DTC_POST_LAUNCH_HTML_FILENAME + state["servers"] += 1 + + def server_url(self) -> str: + return "http://localhost:1234/" + + monkeypatch.setattr("src.web_server.WebServer", FakeWebServer) + + class FakeDriver: + def __init__(self, *args, **kwargs) -> None: + state["starting_url"] = kwargs["starting_url"] + + def execute_drive_thru_cards_order(self, order, pdf_path) -> None: + state["executed"] += 1 + + def __del__(self) -> None: + state["finalized"] += 1 + + monkeypatch.setattr("src.driver.AutofillDriver", FakeDriver) + + def fake_wait_for_user_to_complete_order() -> None: + gc.collect() + state["wait_seen"] = state["finalized"] + + monkeypatch.setattr(autofill_cli, "wait_for_user_to_complete_order", fake_wait_for_user_to_complete_order) + + args = [ + "--directory", + str(tmp_path), + "--site", + constants.TargetSites.DriveThruCards.name, + "--browser", + constants.Browsers.chrome.name, + ] + if skip_instructions: + args.append("--skip-dtc-instructions") + + result = CliRunner().invoke(autofill_cli.main, args) + + gc.collect() + + assert result.exit_code == 0 + assert state["executed"] == 1 + assert state["wait_seen"] == 0 + assert state["finalized"] == 1 + assert state["servers"] == expected_server_count + assert state["starting_url"] == expected_starting_url + + +def test_download_images_for_orders_downloads_fronts_and_backs() -> None: + calls = {"fronts": 0, "backs": 0} + + class Face: + def __init__(self, key: str) -> None: + self._key = key + self.cards_by_id = {"a": object()} + + def download_images(self, _pool, _download_bar, _post_processing_config): + calls[self._key] += 1 + + order = SimpleNamespace(name="order1", fronts=Face("fronts"), backs=Face("backs"), get_failed_downloads=lambda: []) + + autofill_cli.download_images_for_orders(orders=[order], post_processing_config=DEFAULT_POST_PROCESSING) + + assert calls["fronts"] == 1 + assert calls["backs"] == 1 + + +def test_nuitka_directives_include_runtime_data_and_cached_extraction() -> None: + with open(autofill_cli.__file__, "r", encoding="utf-8") as f: + source = f.read() + assert "--include-data-dir=assets=assets" in source + assert "--include-package-data=certifi" in source + assert "--onefile-tempdir-spec={CACHE_DIR}/mpc-autofill/{VERSION}" in source + + +def test_readme_points_users_to_wiki_for_usage_docs() -> None: + readme_path = os.path.join(os.path.dirname(autofill_cli.__file__), "readme.md") + with open(readme_path, "r", encoding="utf-8") as f: + readme = f.read() + assert "https://github.com/chilli-axe/mpc-autofill/wiki/Desktop-Tool" in readme + assert "--skip-pdf-if-exists" not in readme + assert "--download-images-only" not in readme + + +# endregion + +# region ICC profile resolution + + +def test_find_or_download_dtc_icc_profile_prefers_installed_copy(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + installed_profile = tmp_path / "USWebCoatedSWOP.icc" + installed_profile.write_bytes(b"icc") + monkeypatch.setattr(src.icc, "_candidate_profile_paths", lambda: [tmp_path / "missing.icc", installed_profile]) + monkeypatch.setattr( + src.icc.requests, "get", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("no download")) + ) + + assert src.icc.find_or_download_dtc_icc_profile() == str(installed_profile) + + +def test_find_or_download_dtc_icc_profile_returns_none_when_download_declined( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + monkeypatch.setattr(src.icc, "_candidate_profile_paths", lambda: [tmp_path / "missing.icc"]) + monkeypatch.setattr(src.icc.click, "confirm", lambda *_args, **_kwargs: False) + + assert src.icc.find_or_download_dtc_icc_profile() is None + + +def _fake_adobe_bundle_response(profile_bytes: bytes) -> SimpleNamespace: + import io as io_module + import zipfile + + buffer = io_module.BytesIO() + with zipfile.ZipFile(buffer, "w") as bundle: + bundle.writestr(src.icc.ADOBE_ICC_BUNDLE_MEMBER, profile_bytes) + return SimpleNamespace(content=buffer.getvalue(), raise_for_status=lambda: None) + + +def test_find_or_download_dtc_icc_profile_downloads_and_caches(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + import hashlib + + profile_bytes = b"fake icc profile" + monkeypatch.setattr(src.icc, "_candidate_profile_paths", lambda: [tmp_path / "missing.icc"]) + monkeypatch.setattr(src.icc, "get_profile_cache_path", lambda: tmp_path / "cache" / "USWebCoatedSWOP.icc") + monkeypatch.setattr(src.icc.click, "confirm", lambda *_args, **_kwargs: True) + monkeypatch.setattr(src.icc, "ICC_PROFILE_SHA256", hashlib.sha256(profile_bytes).hexdigest()) + monkeypatch.setattr(src.icc.requests, "get", lambda *_args, **_kwargs: _fake_adobe_bundle_response(profile_bytes)) + + resolved = src.icc.find_or_download_dtc_icc_profile() + + assert resolved == str(tmp_path / "cache" / "USWebCoatedSWOP.icc") + assert (tmp_path / "cache" / "USWebCoatedSWOP.icc").read_bytes() == profile_bytes + + +def test_find_or_download_dtc_icc_profile_rejects_checksum_mismatch(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + monkeypatch.setattr(src.icc, "_candidate_profile_paths", lambda: [tmp_path / "missing.icc"]) + monkeypatch.setattr(src.icc, "get_profile_cache_path", lambda: tmp_path / "cache" / "USWebCoatedSWOP.icc") + monkeypatch.setattr(src.icc.click, "confirm", lambda *_args, **_kwargs: True) + monkeypatch.setattr(src.icc.requests, "get", lambda *_args, **_kwargs: _fake_adobe_bundle_response(b"tampered")) + + assert src.icc.find_or_download_dtc_icc_profile() is None + assert not (tmp_path / "cache" / "USWebCoatedSWOP.icc").exists() + + +# endregion + +# region PDF/X conversion + + +def test_convert_pdf_to_pdfx_writes_output_atomically(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + source_path = tmp_path / "source.pdf" + output_path = tmp_path / "output.pdf" + source_path.write_bytes(b"source") + + def fake_run(cmd, capture_output=True, text=True): + output_arg = next(arg for arg in cmd if arg.startswith("-sOutputFile=")) + Path(output_arg.split("=", 1)[1]).write_bytes(b"%PDF-1.3 (PDF/X-1a:2001) /OutputIntents") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("src.pdf_maker.get_ghostscript_path", lambda _path=None: "/opt/homebrew/bin/gs") + monkeypatch.setattr("src.pdf_maker.subprocess.run", fake_run) + + assert convert_pdf_to_pdfx( + str(source_path), + str(output_path), + PdfXConversionConfig(icc_profile_path="dummy.icc"), + ) + assert output_path.read_bytes() == b"%PDF-1.3 (PDF/X-1a:2001) /OutputIntents" + assert sorted(path.name for path in tmp_path.iterdir()) == ["output.pdf", "source.pdf"] + + +def test_convert_pdf_to_pdfx_does_not_leave_partial_output_on_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + source_path = tmp_path / "source.pdf" + output_path = tmp_path / "output.pdf" + source_path.write_bytes(b"source") + output_path.write_bytes(b"previous") + + def fake_run(cmd, capture_output=True, text=True): + output_arg = next(arg for arg in cmd if arg.startswith("-sOutputFile=")) + Path(output_arg.split("=", 1)[1]).write_bytes(b"partial") + return SimpleNamespace(returncode=1, stdout="bad", stderr="worse") + + monkeypatch.setattr("src.pdf_maker.get_ghostscript_path", lambda _path=None: "/opt/homebrew/bin/gs") + monkeypatch.setattr("src.pdf_maker.subprocess.run", fake_run) + + assert not convert_pdf_to_pdfx( + str(source_path), + str(output_path), + PdfXConversionConfig(icc_profile_path="dummy.icc"), + ) + assert output_path.read_bytes() == b"previous" + assert sorted(path.name for path in tmp_path.iterdir()) == ["output.pdf", "source.pdf"] + + +def test_convert_pdf_to_pdfx_rejects_output_missing_pdfx_markers(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + source_path = tmp_path / "source.pdf" + output_path = tmp_path / "output.pdf" + source_path.write_bytes(b"source") + output_path.write_bytes(b"previous") + + def fake_run(cmd, capture_output=True, text=True): + # Zero exit code, but the output is a plain PDF rather than PDF/X-1a. + output_arg = next(arg for arg in cmd if arg.startswith("-sOutputFile=")) + Path(output_arg.split("=", 1)[1]).write_bytes(b"%PDF-1.3 plain") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr("src.pdf_maker.get_ghostscript_path", lambda _path=None: "/opt/homebrew/bin/gs") + monkeypatch.setattr("src.pdf_maker.subprocess.run", fake_run) + + assert not convert_pdf_to_pdfx( + str(source_path), + str(output_path), + PdfXConversionConfig(icc_profile_path="dummy.icc"), + ) + assert output_path.read_bytes() == b"previous" + + +@pytest.mark.skipif(src.pdf_maker.get_ghostscript_path() is None, reason="Ghostscript is not installed") +def test_convert_pdf_to_pdfx_produces_verified_pdfx_with_real_ghostscript(tmp_path) -> None: + from fpdf import FPDF + + image_path = tmp_path / "card.jpg" + Image.new("RGB", (819, 1113), (200, 30, 40)).save(image_path, "JPEG") + pdf = FPDF("P", "in", (2.73, 3.71)) + pdf.add_page() + pdf.image(str(image_path), x=0, y=0, w=2.73, h=3.71) + source_path = tmp_path / "source.pdf" + pdf.output(str(source_path)) + output_path = tmp_path / "output_pdfx.pdf" + + assert convert_pdf_to_pdfx(str(source_path), str(output_path), PdfXConversionConfig()) + + contents = output_path.read_bytes() + assert b"(PDF/X-1:2001)" in contents # GTS_PDFXVersion + assert b"(PDF/X-1a:2001)" in contents # GTS_PDFXConformance + assert b"/OutputIntents" in contents + assert b"CGATS TR 001" in contents + + +@requires_google_drive_credentials +def test_pdf_exporter_appends_pdfx_on_success(monkeypatch: pytest.MonkeyPatch, card_order_valid) -> None: + def do_nothing(_): + return None + + def fake_convert_pdf_to_pdfx(source_path: str, output_path: str, _config) -> bool: + with open(output_path, "wb") as f: + f.write(b"pdfx") + return True + + monkeypatch.setattr("src.pdf_maker.PdfExporter.ask_questions", do_nothing) + monkeypatch.setattr("src.pdf_maker.convert_pdf_to_pdfx", fake_convert_pdf_to_pdfx) + + card_order_valid.name = "test_order.xml" + pdf_exporter = PdfExporter( + order=card_order_valid, + number_of_cards_per_file=1, + pdfx_config=PdfXConversionConfig(icc_profile_path="dummy.icc"), + ) + generated_files = pdf_exporter.execute(post_processing_config=DEFAULT_POST_PROCESSING) + + expected_pdfx_files = [ + Path("export/test_order/1_pdfx.pdf"), + Path("export/test_order/2_pdfx.pdf"), + Path("export/test_order/3_pdfx.pdf"), + ] + for file_path in expected_pdfx_files: + assert file_path in map(Path, generated_files) + assert os.path.exists(file_path) + + remove_files([path for path in generated_files if path.endswith(".pdf")]) + remove_directories(["export/test_order", "export"]) + + +@requires_google_drive_credentials +def test_pdf_exporter_skips_pdfx_on_failure(monkeypatch: pytest.MonkeyPatch, card_order_valid) -> None: + def do_nothing(_): + return None + + monkeypatch.setattr("src.pdf_maker.PdfExporter.ask_questions", do_nothing) + monkeypatch.setattr("src.pdf_maker.convert_pdf_to_pdfx", lambda *_args, **_kwargs: False) + + card_order_valid.name = "test_order.xml" + pdf_exporter = PdfExporter( + order=card_order_valid, + number_of_cards_per_file=1, + pdfx_config=PdfXConversionConfig(icc_profile_path="dummy.icc"), + ) + generated_files = pdf_exporter.execute(post_processing_config=DEFAULT_POST_PROCESSING) + + assert not any(path.endswith("_pdfx.pdf") for path in generated_files) + + remove_files([path for path in generated_files if path.endswith(".pdf")]) + remove_directories(["export/test_order", "export"]) + + +@requires_google_drive_credentials +def test_pdf_exporter_logs_pdfx_conversion_progress(monkeypatch: pytest.MonkeyPatch, card_order_valid) -> None: + logged_messages = [] + + def do_nothing(_): + return None + + def fake_info(message: str): + logged_messages.append(message) + + def fake_convert_pdf_to_pdfx(source_path: str, output_path: str, _config) -> bool: + with open(output_path, "wb") as f: + f.write(b"pdfx") + return True + + monkeypatch.setattr("src.pdf_maker.PdfExporter.ask_questions", do_nothing) + monkeypatch.setattr("src.pdf_maker.convert_pdf_to_pdfx", fake_convert_pdf_to_pdfx) + monkeypatch.setattr("src.pdf_maker.logger.info", fake_info) + + card_order_valid.name = "test_order.xml" + pdf_exporter = PdfExporter( + order=card_order_valid, + number_of_cards_per_file=1, + pdfx_config=PdfXConversionConfig(icc_profile_path="dummy.icc"), + ) + generated_files = pdf_exporter.execute(post_processing_config=DEFAULT_POST_PROCESSING) + + progress_logs = [message for message in logged_messages if message.startswith("Converting PDF to PDF/X-1a")] + assert len(progress_logs) == 3 + assert Path(progress_logs[0].rsplit(": ", 1)[1]) == Path("export/test_order/1.pdf") + + remove_files([path for path in generated_files if path.endswith(".pdf")]) + remove_directories(["export/test_order", "export"]) + + +def test_pdf_exporter_add_image_uses_image_bytes(monkeypatch: pytest.MonkeyPatch, card_order_valid, tmp_path) -> None: + monkeypatch.setattr("src.pdf_maker.PdfExporter.ask_questions", lambda _self: None) + pdf_exporter = PdfExporter(order=card_order_valid, number_of_cards_per_file=1) + pdf_exporter.generate_pdf() + + image_path = tmp_path / "sample.png" + Image.new("RGB", (4, 4), "red").save(image_path) + + captured_name = {"value": None} + + def fake_image(name, **_kwargs): + captured_name["value"] = name + + monkeypatch.setattr(pdf_exporter.pdf, "image", fake_image) + + pdf_exporter.add_image(str(image_path)) + + assert isinstance(captured_name["value"], bytes) + + # endregion # region constants @@ -523,6 +1550,7 @@ def card_order_element_missing_front_image() -> Generator[ElementTree.Element, N # region test utils.py +@requires_google_drive_credentials def test_get_google_drive_file_name(): assert get_google_drive_file_name(SIMPLE_LOTUS_ID) == f"{SIMPLE_LOTUS}.png" assert get_google_drive_file_name(SIMPLE_CUBE_ID) == f"{SIMPLE_CUBE}.png" @@ -553,6 +1581,7 @@ def test_generate_file_path_infer_local_file(image_element_local_file_inferred_t assert image.source_type == SourceType.LOCAL_FILE +@requires_google_drive_credentials def test_download_google_drive_image_default_post_processing( image_valid_google_drive: CardImage, counter: Counter, queue: Queue[CardImage] ): @@ -573,6 +1602,7 @@ def test_download_local_file_is_no_op(image_local_file: CardImage, counter: Coun assert_file_size(image_local_file.file_path, file_size) +@requires_google_drive_credentials def test_download_google_drive_image_downscaled( image_valid_google_drive: CardImage, counter: Counter, queue: Queue[CardImage] ): @@ -588,6 +1618,7 @@ def test_download_google_drive_image_downscaled( assert_file_size(image_valid_google_drive.file_path, 51123) +@requires_google_drive_credentials def test_download_google_drive_image_no_post_processing( image_valid_google_drive: CardImage, counter: Counter, queue: Queue[CardImage] ): @@ -597,6 +1628,7 @@ def test_download_google_drive_image_no_post_processing( assert_file_size(image_valid_google_drive.file_path, 155686) +@requires_google_drive_credentials def test_invalid_google_drive_image(image_invalid_google_drive: CardImage, counter: Counter, queue: Queue[CardImage]): image_invalid_google_drive.download_image( download_bar=counter, queue=queue, post_processing_config=DEFAULT_POST_PROCESSING @@ -604,6 +1636,26 @@ def test_invalid_google_drive_image(image_invalid_google_drive: CardImage, count assert image_invalid_google_drive.errored is True +def test_failed_image_summary_is_logged_with_name_slots_and_link(monkeypatch, tmp_path, caplog): + image = CardImage( + drive_id="missing-drive-id", + slots={2, 5}, + name="Missing Card.png", + file_path=str(tmp_path / "Missing Card.png"), + ) + monkeypatch.setattr("src.order.download_google_drive_file", lambda **_kwargs: False) + progress = SimpleNamespace(update=lambda: None, refresh=lambda: None) + + with caplog.at_level(logging.ERROR, logger="src.logging"): + image.download_image(queue=Queue(), download_bar=progress, post_processing_config=None) + + message = caplog.text + assert "Missing Card.png" in message + assert "[2, 5]" in message + assert "missing-drive-id" in message + + +@requires_google_drive_credentials def test_retrieve_card_name_and_download_file(image_google_valid_drive_no_name, counter, queue): assert image_google_valid_drive_no_name.name == f"{SIMPLE_CUBE}.png" assert not image_google_valid_drive_no_name.file_exists() @@ -644,6 +1696,7 @@ def test_combine_images(image_a, image_b, expected_result): # region test CardImageCollection +@requires_google_drive_credentials def test_card_image_collection_download(card_image_collection_valid, counter, image_google_valid_drive_no_name, pool): assert card_image_collection_valid.slots() == {0, 1, 2} assert [x.file_exists() for x in card_image_collection_valid.cards_by_id.values()] == [False, True] @@ -739,6 +1792,7 @@ def test_card_order_valid(card_order_valid): ) +@requires_google_drive_credentials def test_card_order_multiple_cardbacks(card_order_multiple_cardbacks): assert_orders_identical( card_order_multiple_cardbacks, @@ -792,6 +1846,7 @@ def test_card_order_multiple_cardbacks(card_order_multiple_cardbacks): ) +@requires_google_drive_credentials def test_card_order_valid_from_file(): card_order = CardOrder.from_file_path(working_directory=FILE_PATH, file_path="test_order.xml") for card in (card_order.fronts.cards_by_id | card_order.backs.cards_by_id).values(): @@ -1379,6 +2434,7 @@ def key(order: CardOrder) -> int: # region test PdfExporter +@requires_google_drive_credentials def test_pdf_export_complete_3_cards_single_file(monkeypatch, card_order_valid): def do_nothing(_): return None @@ -1388,6 +2444,9 @@ def do_nothing(_): pdf_exporter = PdfExporter(order=card_order_valid) pdf_exporter.execute(post_processing_config=DEFAULT_POST_PROCESSING) + assert pdf_exporter.processed_bar.total == 3 + assert pdf_exporter.processed_bar.count == 3 + expected_generated_files = [ "export/test_order/1.pdf", ] @@ -1398,6 +2457,7 @@ def do_nothing(_): remove_directories(["export/test_order", "export"]) +@requires_google_drive_credentials def test_pdf_export_complete_3_cards_separate_files(monkeypatch, card_order_valid): def do_nothing(_): return None @@ -1415,6 +2475,7 @@ def do_nothing(_): remove_directories(["export/test_order", "export"]) +@requires_google_drive_credentials def test_pdf_export_complete_separate_faces(monkeypatch, card_order_valid): def do_nothing(_): return None @@ -1424,6 +2485,9 @@ def do_nothing(_): pdf_exporter = PdfExporter(order=card_order_valid, separate_faces=True, number_of_cards_per_file=1) pdf_exporter.execute(post_processing_config=DEFAULT_POST_PROCESSING) + assert pdf_exporter.processed_bar.total == 3 + assert pdf_exporter.processed_bar.count == 3 + expected_generated_files = [ "export/test_order/backs/1.pdf", "export/test_order/backs/2.pdf", @@ -1439,6 +2503,162 @@ def do_nothing(_): remove_directories(["export/test_order/backs", "export/test_order/fronts", "export/test_order", "export"]) +def test_pdf_export_stops_before_creating_pdf_when_an_image_download_fails(monkeypatch, card_order_valid): + monkeypatch.setattr("src.pdf_maker.PdfExporter.ask_questions", lambda _self: None) + + def download_fronts(*_args): + for index, card in enumerate(card_order_valid.fronts.cards_by_id.values()): + card.downloaded = index != 0 + + def download_backs(*_args): + for card in card_order_valid.backs.cards_by_id.values(): + card.downloaded = True + + monkeypatch.setattr(card_order_valid.fronts, "download_images", download_fronts) + monkeypatch.setattr(card_order_valid.backs, "download_images", download_backs) + exporter = PdfExporter(order=card_order_valid) + monkeypatch.setattr(exporter, "export", lambda: pytest.fail("PDF export should not start")) + manager_stop_calls = [] + monkeypatch.setattr(exporter.manager, "stop", lambda: manager_stop_calls.append(True)) + + with pytest.raises(ImageDownloadError, match="Import this XML into a new project at"): + exporter.execute(post_processing_config=DEFAULT_POST_PROCESSING) + + assert exporter.saved_files == [] + # the terminal rows must be released even when the export aborts + assert manager_stop_calls == [True] + + +def test_pdf_export_drive_thru_cards_combines_actual_front_slots_into_one_file(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + for image_name, color in [("front_a.png", "red"), ("front_b.png", "blue"), ("back.png", "black")]: + Image.new("RGB", (300, 420), color).save(tmp_path / image_name) + + order = CardOrder.from_element( + working_directory=str(tmp_path), + element=ElementTree.fromstring( + textwrap.dedent( + f""" + +
+ 1 + (S30) Standard Smooth + false +
+ + + {tmp_path / "front_a.png"} + {SourceType.LOCAL_FILE} + 0 + front_a.png + + + {tmp_path / "front_b.png"} + {SourceType.LOCAL_FILE} + 1 + front_b.png + + + + {tmp_path / "back.png"} +
+ """ + ) + ), + allowed_to_exceed_project_max_size=True, + ) + order.name = "test_local.xml" + + exporter = PdfExporter(order=order, export_mode="drive_thru_cards") + manager_stop_calls = [] + monkeypatch.setattr(exporter.manager, "stop", lambda: manager_stop_calls.append(True)) + generated_files = exporter.execute( + post_processing_config=ImagePostProcessingConfig( + max_dpi=300, + downscale_alg=constants.ImageResizeMethods.LANCZOS, + output_format="JPEG", + convert_to_cmyk=False, + ) + ) + + assert list(map(Path, generated_files)) == [Path("export/test_local/1.pdf")] + assert os.path.exists("export/test_local/1.pdf") + assert count_pdf_pages("export/test_local/1.pdf") == 4 + # progress bars are frozen into scrollback once the export completes + assert manager_stop_calls == [True] + + +def test_pdf_export_drive_thru_cards_processes_and_embeds_repeated_images_once(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + + for image_name, color in [("front_a.png", "red"), ("front_b.png", "blue"), ("back.png", "black")]: + Image.new("RGB", (300, 420), color).save(tmp_path / image_name) + + process_calls: list[None] = [] + real_post_process_image = src.pdf_maker.post_process_image + + def counting_post_process_image(raw_image, config): + process_calls.append(None) + return real_post_process_image(raw_image=raw_image, config=config) + + monkeypatch.setattr("src.pdf_maker.post_process_image", counting_post_process_image) + + order = CardOrder.from_element( + working_directory=str(tmp_path), + element=ElementTree.fromstring( + textwrap.dedent( + f""" + +
+ 1 + (S30) Standard Smooth + false +
+ + + {tmp_path / "front_a.png"} + {SourceType.LOCAL_FILE} + 0 + front_a.png + + + {tmp_path / "front_b.png"} + {SourceType.LOCAL_FILE} + 1 + front_b.png + + + + {tmp_path / "back.png"} +
+ """ + ) + ), + allowed_to_exceed_project_max_size=True, + ) + order.name = "test_dedup.xml" + + exporter = PdfExporter(order=order, export_mode="drive_thru_cards") + exporter.execute( + post_processing_config=ImagePostProcessingConfig( + max_dpi=300, + downscale_alg=constants.ImageResizeMethods.LANCZOS, + output_format="JPEG", + convert_to_cmyk=False, + ) + ) + + # 4 pages (back, front_a, back, front_b) but only 3 unique images: the shared + # cardback must be post-processed once and its JPEG data embedded once. + assert count_pdf_pages("export/test_dedup/1.pdf") == 4 + assert len(process_calls) == 3 + with open("export/test_dedup/1.pdf", "rb") as f: + assert f.read().count(b"DCTDecode") == 3 + # temp files are cleaned up after execute() + assert exporter.processed_image_paths == {} + + # endregion # region test driver.py @@ -1457,6 +2677,7 @@ def do_nothing(_): constants.TargetSites.PrinterStudioFR, ], ) +@requires_google_drive_credentials def test_card_order_complete_run_single_cardback(browser, site, input_enter, card_order_valid): autofill_driver = AutofillDriver(browser=browser, target_site=site, headless=True) autofill_driver.execute_order( @@ -1488,6 +2709,7 @@ def test_card_order_complete_run_single_cardback(browser, site, input_enter, car constants.TargetSites.PrinterStudioFR, ], ) +@requires_google_drive_credentials def test_card_order_complete_run_multiple_cardbacks(browser, site, input_enter, card_order_multiple_cardbacks): autofill_driver = AutofillDriver(browser=browser, target_site=site, headless=True) autofill_driver.execute_order( @@ -1507,3 +2729,106 @@ def test_card_order_complete_run_multiple_cardbacks(browser, site, input_enter, # endregion + + +def test_console_formatter_hides_tracebacks_but_default_formatter_keeps_them(): + from src.logging import ConsoleFormatter + + try: + raise ValueError("boom") + except ValueError: + record = logging.LogRecord( + name="src.logging", + level=logging.ERROR, + pathname=__file__, + lineno=1, + msg="download failed", + args=(), + exc_info=sys.exc_info(), + ) + + assert ConsoleFormatter().format(record) == "download failed" + # the record itself is untouched, so the crash log formatter still sees the traceback + assert "Traceback" in logging.Formatter().format(record) + + +def test_execute_order_stops_before_upload_when_downloads_fail(monkeypatch, card_order_valid): + monkeypatch.setattr(AutofillDriver, "__attrs_post_init__", lambda self: None) + driver = AutofillDriver(target_site=constants.TargetSites.MakePlayingCards) + driver.initialise_bars() + + def fail_fronts(**_kwargs): + for index, card in enumerate(card_order_valid.fronts.cards_by_id.values()): + card.downloaded = index != 0 + + def download_backs(**_kwargs): + for card in card_order_valid.backs.cards_by_id.values(): + card.downloaded = True + + monkeypatch.setattr(card_order_valid.fronts, "download_images", fail_fronts) + monkeypatch.setattr(card_order_valid.backs, "download_images", download_backs) + monkeypatch.setattr(driver, "initialise_order", lambda **_kwargs: pytest.fail("upload must not start")) + + with pytest.raises(ImageDownloadError, match="stopped before creating your order"): + driver.execute_order( + order=card_order_valid, + fulfilment_method=OrderFulfilmentMethod.new_project, + auto_save_threshold=None, + post_processing_config=None, + ) + + +def test_prune_stale_onefile_caches_removes_only_sibling_version_dirs(tmp_path): + cache_root = tmp_path / "mpc-autofill" + current = cache_root / "1.0.2" + stale = cache_root / "1.0.1" + for directory in (current, stale): + directory.mkdir(parents=True) + (directory / "autofill.bin").touch() + (cache_root / "unrelated-file.txt").touch() + + autofill_cli.prune_stale_onefile_caches(str(current)) + + assert current.exists() + assert not stale.exists() + assert (cache_root / "unrelated-file.txt").exists() + + # refuses to delete anything when not inside an mpc-autofill cache directory + other = tmp_path / "somewhere-else" / "1.0.2" + other_sibling = tmp_path / "somewhere-else" / "1.0.1" + other.mkdir(parents=True) + other_sibling.mkdir(parents=True) + autofill_cli.prune_stale_onefile_caches(str(other)) + assert other_sibling.exists() + + +def test_console_filter_hides_file_only_records(): + from src.logging import FILE_ONLY, _console_visible + + visible = logging.LogRecord( + name="src.logging", level=logging.ERROR, pathname=__file__, lineno=1, msg="shown", args=(), exc_info=None + ) + hidden = logging.LogRecord( + name="src.logging", level=logging.ERROR, pathname=__file__, lineno=1, msg="hidden", args=(), exc_info=None + ) + for key, value in FILE_ONLY.items(): + setattr(hidden, key, value) + + assert _console_visible(visible) is True + assert _console_visible(hidden) is False + + +def test_download_images_only_raises_summary_when_downloads_fail(monkeypatch, card_order_valid): + def fail_fronts(*_args, **_kwargs): + for card in card_order_valid.fronts.cards_by_id.values(): + card.downloaded = False + + def download_backs(*_args, **_kwargs): + for card in card_order_valid.backs.cards_by_id.values(): + card.downloaded = True + + monkeypatch.setattr(card_order_valid.fronts, "download_images", fail_fronts) + monkeypatch.setattr(card_order_valid.backs, "download_images", download_backs) + + with pytest.raises(ImageDownloadError, match="stopped before creating your order"): + autofill_cli.download_images_for_orders(orders=[card_order_valid], post_processing_config=None) diff --git a/desktop-tool/tests/test_drivethrucards_driver.py b/desktop-tool/tests/test_drivethrucards_driver.py new file mode 100644 index 000000000..6d9cbabfb --- /dev/null +++ b/desktop-tool/tests/test_drivethrucards_driver.py @@ -0,0 +1,522 @@ +import time +from types import SimpleNamespace + +import pytest +from selenium.common import exceptions as sl_exc +from selenium.webdriver.common.by import By + +from src.constants import TargetSites +from src.driver import AutofillDriver + + +@pytest.fixture +def dtc_driver(monkeypatch: pytest.MonkeyPatch) -> AutofillDriver: + monkeypatch.setattr(AutofillDriver, "__attrs_post_init__", lambda self: None) + driver = AutofillDriver(target_site=TargetSites.DriveThruCards) + driver.set_state = lambda *_args, **_kwargs: None # avoid status bar dependency in unit tests + return driver + + +def test_execute_drive_thru_cards_order_runs_expected_sequence(dtc_driver: AutofillDriver) -> None: + calls = [] + dtc_driver.driver = SimpleNamespace() + + dtc_driver.open_dtc_starting_page = lambda: calls.append(("open_dtc_starting_page",)) + dtc_driver.wait_for_cloudflare_challenge = lambda: calls.append(("wait_for_cloudflare_challenge",)) + dtc_driver.authenticate_dtc = lambda: calls.append(("authenticate_dtc",)) or True + dtc_driver.ensure_dtc_publisher_account = lambda: calls.append(("ensure_dtc_publisher_account",)) + dtc_driver.navigate_to_dtc_product_setup = lambda: calls.append(("navigate_to_dtc_product_setup",)) + dtc_driver.fill_dtc_product_form = lambda order: calls.append(("fill_dtc_product_form", order.name)) + dtc_driver.submit_dtc_description_page = lambda: calls.append(("submit_dtc_description_page",)) + dtc_driver.open_dtc_upload_page = lambda: calls.append(("open_dtc_upload_page",)) + dtc_driver.select_card_type_and_upload_pdf = lambda pdf_path: calls.append(("upload_pdf", pdf_path)) + + order = SimpleNamespace(name="My Order") + dtc_driver.execute_drive_thru_cards_order(order=order, pdf_path="/tmp/order.pdf") + + assert calls == [ + ("open_dtc_starting_page",), + ("wait_for_cloudflare_challenge",), + ("authenticate_dtc",), + ("ensure_dtc_publisher_account",), + ("navigate_to_dtc_product_setup",), + ("fill_dtc_product_form", "My Order"), + ("submit_dtc_description_page",), + ("open_dtc_upload_page",), + ("upload_pdf", "/tmp/order.pdf"), + ] + + +def test_authenticate_dtc_returns_immediately_when_already_logged_in(dtc_driver: AutofillDriver) -> None: + dtc_driver.is_dtc_user_authenticated = lambda: True + dtc_driver._click_dtc_login_button = lambda: (_ for _ in ()).throw(AssertionError("should not click")) + dtc_driver.click_element_polling = lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("should not poll") + ) + + assert dtc_driver.authenticate_dtc() is True + + +def test_authenticated_selector_matches_current_account_menu() -> None: + selector = TargetSites.DriveThruCards.value.selectors.authenticated_indicator_selector + + assert "[data-cy='accountMenu']" in selector + assert "[aria-label='Log Out']" in selector + + +def test_authenticate_dtc_opens_login_pane_then_waits( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + auth_checks = iter([False, False, True]) + dtc_driver.is_dtc_user_authenticated = lambda: next(auth_checks) + dtc_driver._click_dtc_login_button = lambda: True + polling_calls = [] + dtc_driver.click_element_polling = ( + lambda by, selector, timeout=30: polling_calls.append((by, selector, timeout)) or True + ) + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + + assert dtc_driver.authenticate_dtc() is True + assert polling_calls[0][0] == By.XPATH + assert "Go to Log In" in polling_calls[0][1] + assert polling_calls[0][2] == 15 + + +def test_authenticate_dtc_returns_false_on_timeout(monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver) -> None: + dtc_driver.is_dtc_user_authenticated = lambda: False + dtc_driver._click_dtc_login_button = lambda: False + dtc_driver.click_element_polling = lambda *_args, **_kwargs: False + + time_values = iter([0.0, 301.0]) + monkeypatch.setattr(time, "time", lambda: next(time_values)) + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + + assert dtc_driver.authenticate_dtc() is False + + +def test_execute_drive_thru_cards_order_raises_when_login_not_completed(dtc_driver: AutofillDriver) -> None: + dtc_driver.driver = SimpleNamespace() + dtc_driver.open_dtc_starting_page = lambda: None + dtc_driver.wait_for_cloudflare_challenge = lambda: None + dtc_driver.authenticate_dtc = lambda: False + dtc_driver.navigate_to_dtc_product_setup = lambda: (_ for _ in ()).throw(AssertionError("should not continue")) + + with pytest.raises(Exception, match="login was not completed"): + dtc_driver.execute_drive_thru_cards_order(order=SimpleNamespace(name="x"), pdf_path="/tmp/x.pdf") + + +def test_execute_drive_thru_cards_order_wraps_step_failures_with_context(dtc_driver: AutofillDriver) -> None: + dtc_driver.driver = SimpleNamespace() + dtc_driver.open_dtc_starting_page = lambda: None + dtc_driver.wait_for_cloudflare_challenge = lambda: None + dtc_driver.authenticate_dtc = lambda: True + dtc_driver.ensure_dtc_publisher_account = lambda: None + dtc_driver.navigate_to_dtc_product_setup = lambda: (_ for _ in ()).throw(RuntimeError("new UI mismatch")) + + with pytest.raises(Exception, match="step 'navigate_to_dtc_product_setup' failed"): + dtc_driver.execute_drive_thru_cards_order(order=SimpleNamespace(name="x"), pdf_path="/tmp/x.pdf") + + +def test_initialise_driver_retries_when_initial_window_is_already_closed( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + created = [] + + class FailingDriver: + def __init__(self) -> None: + self.quit_called = False + + def set_window_size(self, *_args, **_kwargs) -> None: + raise sl_exc.NoSuchWindowException("target window already closed") + + def quit(self) -> None: + self.quit_called = True + + class WorkingDriver: + def __init__(self) -> None: + self.calls = [] + + def set_window_size(self, width: int, height: int) -> None: + self.calls.append(("set_window_size", width, height)) + + def implicitly_wait(self, seconds: int) -> None: + self.calls.append(("implicitly_wait", seconds)) + + def get(self, url: str) -> None: + self.calls.append(("get", url)) + + def quit(self) -> None: + self.calls.append(("quit",)) + + failing_driver = FailingDriver() + working_driver = WorkingDriver() + drivers = [failing_driver, working_driver] + + def fake_browser_factory(**_kwargs): + driver = drivers.pop(0) + created.append(driver) + return driver + + monkeypatch.setattr("src.driver.get_undetected_chrome_driver", fake_browser_factory) + dtc_driver.starting_url = TargetSites.DriveThruCards.value.starting_url + monkeypatch.setattr("src.driver.WebDriverWait", lambda *_args, **_kwargs: SimpleNamespace(until=lambda _cond: True)) + monkeypatch.setattr("src.driver.time.sleep", lambda _seconds: None) + + dtc_driver.initialise_driver() + + assert created == [failing_driver, working_driver] + assert failing_driver.quit_called is True + assert dtc_driver.driver is working_driver + assert ("set_window_size", 1200, 900) in working_driver.calls + assert ("implicitly_wait", 5) in working_driver.calls + assert ("get", TargetSites.DriveThruCards.value.starting_url) in working_driver.calls + + +def test_navigate_to_dtc_product_setup_uses_fast_polling_and_no_direct_fallback( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + poll_calls = [] + debug_logs = [] + get_calls = [] + + dtc_driver.driver = SimpleNamespace(get=lambda url: get_calls.append(url)) + + def fake_poll(by, selector, timeout=30): + poll_calls.append((by, selector, timeout)) + return True + + dtc_driver.click_element_polling = fake_poll + monkeypatch.setattr("src.driver.logger.debug", lambda msg: debug_logs.append(msg)) + + dtc_driver.navigate_to_dtc_product_setup() + + assert poll_calls == [ + (By.CSS_SELECTOR, TargetSites.DriveThruCards.value.selectors.publisher_ready_selector, 1), + (By.XPATH, "//a[contains(@href, 'pub_enter_product.php')]", 2), + ] + assert "Clicked 'Publisher Tools' link." in debug_logs + assert "Clicked 'Set up a new title' link." in debug_logs + assert get_calls == [] + + +def test_navigate_to_dtc_product_setup_direct_navigates_on_missing_links(dtc_driver: AutofillDriver) -> None: + get_calls = [] + dtc_driver.driver = SimpleNamespace(get=lambda url: get_calls.append(url)) + dtc_driver.click_element_polling = lambda *_args, **_kwargs: False + + dtc_driver.navigate_to_dtc_product_setup() + + assert get_calls == [ + "https://site.drivethrucards.com/pub_tools.php", + "https://tools.drivethrucards.com/pub_enter_product.php", + ] + + +def test_wait_for_cloudflare_challenge_returns_when_site_loaded(dtc_driver: AutofillDriver) -> None: + dtc_driver._is_site_loaded = lambda: True + dtc_driver._is_cloudflare_challenge_active = lambda: (_ for _ in ()).throw( + AssertionError("should not check challenge when site already loaded") + ) + + dtc_driver.wait_for_cloudflare_challenge(timeout_seconds=1) + + +def test_wait_for_cloudflare_challenge_raises_on_timeout( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + dtc_driver._is_site_loaded = lambda: False + dtc_driver._is_cloudflare_challenge_active = lambda: False + time_values = iter([0.0, 2.0]) + monkeypatch.setattr(time, "time", lambda: next(time_values)) + + with pytest.raises(TimeoutError, match="did not finish loading"): + dtc_driver.wait_for_cloudflare_challenge(timeout_seconds=1) + + +def test_ensure_dtc_publisher_account_automates_wizard( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + calls = [] + + class PublisherNameInput: + value = "" + + def clear(self) -> None: + self.value = "" + + def send_keys(self, value: str) -> None: + self.value = value + + class AgreementCheckbox: + clicked = False + + def is_selected(self) -> bool: + return False + + publisher_name = PublisherNameInput() + agreement = AgreementCheckbox() + ready_checks = iter([False, True]) + dtc_driver.is_dtc_publisher_ready = lambda: next(ready_checks) + dtc_driver.driver = SimpleNamespace(get=lambda url: calls.append(("get", url))) + + def fake_click(by: By, selector: str, timeout: int = 30) -> bool: + calls.append(("click", by, selector, timeout)) + return True + + dtc_driver.click_element_polling = fake_click + + def fake_click_with_retry(element) -> bool: + element.clicked = True + return True + + dtc_driver.click_element_with_retry = fake_click_with_retry + + class FakeWait: + count = 0 + + def __init__(self, *_args, **_kwargs) -> None: + pass + + def until(self, condition): + FakeWait.count += 1 + if FakeWait.count == 1: + return publisher_name + if FakeWait.count == 2: + return agreement + return condition(dtc_driver.driver) + + monkeypatch.setattr("src.driver.WebDriverWait", FakeWait) + + dtc_driver.ensure_dtc_publisher_account() + + assert calls[0] == ("get", "https://www.drivethrucards.com/joinchoice.php") + assert len([call for call in calls if call[0] == "click"]) == 3 + assert publisher_name.value == "MPC Autofill Publisher" + assert agreement.clicked is True + + +def test_open_dtc_upload_page_extracts_window_open_url( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + script_calls = [] + + class UploadButton: + def get_attribute(self, name: str) -> str: + assert name == "onclick" + return "window.open('https://tools.drivethrucards.com/pub_upload_podcard_files.php?products_id=123');" + + class FakeWait: + call_count = 0 + + def __init__(self, *_args, **_kwargs): + pass + + def until(self, _condition): + FakeWait.call_count += 1 + if FakeWait.call_count == 1: + return UploadButton() + return object() + + dtc_driver.driver = SimpleNamespace( + execute_script=lambda script, url: script_calls.append((script, url)), + current_url="https://tools.drivethrucards.com/pub_upload_podcard_files.php?products_id=123", + ) + + monkeypatch.setattr("src.driver.WebDriverWait", FakeWait) + + dtc_driver.open_dtc_upload_page() + + assert script_calls == [ + ( + "window.location.href = arguments[0];", + "https://tools.drivethrucards.com/pub_upload_podcard_files.php?products_id=123", + ) + ] + + +def test_is_site_loaded_uses_login_or_logged_in_selectors(dtc_driver: AutofillDriver) -> None: + selectors = TargetSites.DriveThruCards.value.selectors + + class FakeWebDriver: + def __init__(self) -> None: + self.title = "DriveThruCards" + self.wait_values = [] + + def implicitly_wait(self, value: int) -> None: + self.wait_values.append(value) + + def find_elements(self, by: By, selector: str): + if by == By.CSS_SELECTOR and selector == selectors.login_button_selector: + return [object()] + if by == By.CSS_SELECTOR and selector == selectors.authenticated_indicator_selector: + return [] + if by == By.CSS_SELECTOR and selector == selectors.publisher_ready_selector: + return [] + return [] + + fake_driver = FakeWebDriver() + dtc_driver.driver = fake_driver + + assert dtc_driver._is_site_loaded() is True + assert fake_driver.wait_values == [0, 5] + + +def test_create_driver_uses_undetected_chrome_for_dtc( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + captured = {} + + def fake_undetected_chrome(**kwargs): + captured.update(kwargs) + return "uc-driver" + + monkeypatch.setattr("src.driver.get_undetected_chrome_driver", fake_undetected_chrome) + dtc_driver.browser_profile_path = "/tmp/profile" + dtc_driver.browser_profile_name = "Profile 7" + + assert dtc_driver.create_driver() == "uc-driver" + assert captured == { + "headless": False, + "binary_location": None, + "user_data_dir": "/tmp/profile", + "profile_directory": "Profile 7", + } + + +def test_create_driver_uses_standard_factory_for_other_sites(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AutofillDriver, "__attrs_post_init__", lambda self: None) + monkeypatch.setattr( + "src.driver.get_undetected_chrome_driver", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("undetected-chromedriver must not be used here")), + ) + captured = {} + + def fake_factory(headless=False, binary_location=None): + captured.update(headless=headless, binary_location=binary_location) + return "standard-driver" + + autofill_driver = AutofillDriver( + target_site=TargetSites.MakePlayingCards, + browser=SimpleNamespace(value=fake_factory, name="chrome"), + ) + + assert autofill_driver.create_driver() == "standard-driver" + assert captured == {"headless": False, "binary_location": None} + + +class _FakeElement: + def __init__(self, selected: bool = False) -> None: + self.sent: list = [] + self._selected = selected + + def clear(self) -> None: + pass + + def send_keys(self, value: str) -> None: + self.sent.append(value) + + def is_selected(self) -> bool: + return self._selected + + def get_attribute(self, _name: str) -> str: + return "" + + +def _timing_out_wait(*_args, **_kwargs) -> SimpleNamespace: + return SimpleNamespace(until=lambda _cond: (_ for _ in ()).throw(sl_exc.TimeoutException("no element"))) + + +def test_fill_dtc_product_form_raises_when_title_field_is_missing( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + dtc_driver.driver = SimpleNamespace() + monkeypatch.setattr("src.driver.WebDriverWait", _timing_out_wait) + + with pytest.raises(sl_exc.TimeoutException): + dtc_driver.fill_dtc_product_form(order=SimpleNamespace(name="x")) + + +def test_fill_dtc_product_form_raises_when_filter_checkbox_cannot_be_checked( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + dtc_driver.driver = SimpleNamespace(find_element=lambda _by, _value: _FakeElement(selected=False)) + monkeypatch.setattr( + "src.driver.WebDriverWait", lambda *_args, **_kwargs: SimpleNamespace(until=lambda _cond: _FakeElement()) + ) + dtc_driver.click_element_with_retry = lambda _element: False + + with pytest.raises(Exception, match="filter checkbox"): + dtc_driver.fill_dtc_product_form(order=SimpleNamespace(name="x")) + + +def test_submit_dtc_description_page_raises_when_button_is_missing( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + dtc_driver.driver = SimpleNamespace() + monkeypatch.setattr("src.driver.WebDriverWait", _timing_out_wait) + + with pytest.raises(sl_exc.TimeoutException): + dtc_driver.submit_dtc_description_page() + + +def test_open_dtc_upload_page_raises_when_upload_url_cannot_be_extracted( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + button_without_url = SimpleNamespace(get_attribute=lambda _name: "showError(); return false;") + dtc_driver.driver = SimpleNamespace() + monkeypatch.setattr( + "src.driver.WebDriverWait", lambda *_args, **_kwargs: SimpleNamespace(until=lambda _cond: button_without_url) + ) + + with pytest.raises(Exception, match="upload page URL"): + dtc_driver.open_dtc_upload_page() + + +def test_select_card_type_and_upload_pdf_raises_when_euro_poker_option_is_missing( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver +) -> None: + dtc_driver.driver = SimpleNamespace(find_element=lambda _by, _value: _FakeElement()) + monkeypatch.setattr( + "src.driver.WebDriverWait", lambda *_args, **_kwargs: SimpleNamespace(until=lambda _cond: _FakeElement()) + ) + monkeypatch.setattr( + "src.driver.Select", lambda _element: SimpleNamespace(options=[SimpleNamespace(text="Jumbo Card(s)")]) + ) + + with pytest.raises(Exception, match="Euro Poker"): + dtc_driver.select_card_type_and_upload_pdf(pdf_path="/tmp/order.pdf") + + +def test_select_card_type_and_upload_pdf_raises_when_pdf_is_missing( + monkeypatch: pytest.MonkeyPatch, dtc_driver: AutofillDriver, tmp_path +) -> None: + euro_poker_select = SimpleNamespace( + options=[SimpleNamespace(text="Premium Euro Poker Card(s)")], + select_by_visible_text=lambda _text: None, + ) + dtc_driver.driver = SimpleNamespace(find_element=lambda _by, _value: _FakeElement()) + monkeypatch.setattr( + "src.driver.WebDriverWait", lambda *_args, **_kwargs: SimpleNamespace(until=lambda _cond: _FakeElement()) + ) + monkeypatch.setattr("src.driver.Select", lambda _element: euro_poker_select) + + with pytest.raises(Exception, match="PDF file not found"): + dtc_driver.select_card_type_and_upload_pdf(pdf_path=str(tmp_path / "does-not-exist.pdf")) + + +def test_initialise_bars_creates_only_status_bar_for_dtc(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(AutofillDriver, "__attrs_post_init__", lambda self: None) + + dtc = AutofillDriver(target_site=TargetSites.DriveThruCards) + dtc.initialise_bars() + assert dtc.status_bar + assert dtc.order_progress_bar is None + assert dtc.download_bar is None + assert dtc.upload_bar is None + + mpc = AutofillDriver(target_site=TargetSites.MakePlayingCards) + mpc.initialise_bars() + assert mpc.status_bar + assert mpc.order_progress_bar is not None + assert mpc.download_bar is not None + assert mpc.upload_bar is not None