Source code for scitacean.testing.sftp.fixtures

# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2025 SciCat Project (https://github.com/SciCatProject/scitacean)
# mypy: disable-error-code="no-untyped-def"
"""Pytest fixtures to manage and access a local SFTP server."""

import logging
from collections.abc import Callable, Generator
from pathlib import Path

import pytest
from paramiko import SFTPClient, SSHClient

from ..._internal import docker
from .._pytest_helpers import init_work_dir, root_tmp_dir
from ._pytest_helpers import sftp_enabled, skip_if_not_sftp
from ._sftp import (
    IgnorePolicy,
    SFTPAccess,
    configure,
    local_access,
    wait_until_sftp_server_is_live,
)


[docs] @pytest.fixture(scope="session") def sftp_access(request: pytest.FixtureRequest) -> SFTPAccess: """Fixture that returns SFTP access parameters. Returns ------- : A URL and user to connect to the testing SFTP server. The user has access to all initial files registered in the database and permissions to create new files. """ skip_if_not_sftp(request) return local_access()
[docs] @pytest.fixture(scope="session") def sftp_base_dir( request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory ) -> Path | None: """Fixture that returns the base working directory for the SFTP server setup. Returns ------- : A path to a directory on the host machine. The directory gets populated by the :func:`scitacean.testing.sftp.fixtures.sftp_fileserver` fixture. It contains the docker configuration and volumes. Returns ``None`` if SFTP tests are disabled """ if not sftp_enabled(request): return None return root_tmp_dir(request, tmp_path_factory) / "scitacean-sftp"
[docs] @pytest.fixture(scope="session") def sftp_data_dir(sftp_base_dir: Path | None) -> Path | None: """Fixture that returns the data directory for the SFTP server setup. Returns ------- : A path to a directory on the host machine. The directory is mounted as ``/data`` on the server. Returns ``None`` if SFTP tests are disabled """ if sftp_base_dir is None: return None return sftp_base_dir / "data"
[docs] @pytest.fixture def require_sftp_fileserver(request, sftp_fileserver) -> None: """Fixture to declare that a test needs a local SFTP server. Like :func:`scitacean.testing.sftp.sftp_fileserver` but this skips the test if SFTP tests are disabled. """ skip_if_not_sftp(request)
[docs] @pytest.fixture(scope="session") def sftp_fileserver( request: pytest.FixtureRequest, sftp_access: SFTPAccess, sftp_base_dir: Path | None, sftp_data_dir: Path | None, sftp_connect_with_username_password, ) -> Generator[bool, None, None]: """Fixture to declare that a test needs a local SFTP server. If SFTP tests are enabled, this fixture configures and starts an SFTP server in a docker container the first time a test requests it. The server and container will be stopped and removed at the end of the test session. Does nothing if the SFTP tests are disabled. Returns ------- : True if SFTP tests are enabled and False otherwise. """ if sftp_base_dir is None: yield False return target_dir, counter = init_work_dir(request, sftp_base_dir, name=None) try: with counter.increment() as count: if count == 1: _sftp_docker_up(target_dir, sftp_access) elif not _sftp_server_is_running(): raise RuntimeError("Expected SFTP server to be running") yield True finally: with counter.decrement() as count: if count == 0: _sftp_docker_down(target_dir)
[docs] @pytest.fixture(scope="session") def sftp_connect_with_username_password( sftp_access: SFTPAccess, ) -> Callable[[str, int], SFTPClient]: """Fixture that returns a function to connect to the testing SFTP server. Uses username+password from the test config. Returns ------- : A function to pass as the ``connect`` argument when constructing a :class:`scitacean.transfer.sftp.SFTPFileTransfer`. Examples -------- Explicitly connect to the test server: .. code-block:: python def test_sftp(sftp_access, sftp_connect_with_username_password, sftp_fileserver): sftp = SFTPFileTransfer(host=sftp_access.host, port=sftp_access.port, connect=sftp_connect_with_username_password) with sftp.connect_for_download() as connection: # use connection """ def connect(host: str, port: int) -> SFTPClient: client = SSHClient() client.set_missing_host_key_policy(IgnorePolicy()) client.connect( hostname=host, port=port, username=sftp_access.user.username, password=sftp_access.user.password, allow_agent=False, look_for_keys=False, ) return client.open_sftp() return connect
def _sftp_docker_up(target_dir: Path, sftp_access: SFTPAccess) -> None: if _sftp_server_is_running(): raise RuntimeError("SFTP docker container is already running") docker_compose_file = target_dir / "docker-compose.yaml" log = logging.getLogger("scitacean.testing") log.info("Starting docker container with SFTP server from %s", docker_compose_file) configure(target_dir) docker.docker_compose_up(docker_compose_file) log.info("Waiting for SFTP docker to become accessible") wait_until_sftp_server_is_live(sftp_access=sftp_access, max_time=60, n_tries=40) log.info("Successfully connected to SFTP server") # Give the user write access. docker.docker_compose_run( docker_compose_file, "scitacean-test-sftp-server", "chown", "1000:1000", "/data" ) def _sftp_docker_down(target_dir: Path) -> None: # Check if container is running because the fixture can call this function # if there was an exception in _sftp_docker_up. # In that case, there is nothing to tear down. if _sftp_server_is_running(): docker_compose_file = target_dir / "docker-compose.yaml" log = logging.getLogger("scitacean.testing") log.info( "Stopping docker container with SFTP server from %s", docker_compose_file ) docker.docker_compose_down(docker_compose_file) def _sftp_server_is_running() -> bool: return docker.container_is_running("scitacean-test-sftp")