Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog-fragments/664.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved performance of SFTP transfers by using larger transfer chunks -- by :user:`Jakuje`.
4 changes: 4 additions & 0 deletions src/pylibsshext/includes/libssh.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ cdef extern from "libssh/libssh.h" nogil:
pass
ctypedef ssh_session_struct* ssh_session

cdef struct ssh_string_struct:
pass
ctypedef ssh_string_struct* ssh_string

cdef struct ssh_key_struct:
pass
ctypedef ssh_key_struct* ssh_key
Expand Down
32 changes: 31 additions & 1 deletion src/pylibsshext/includes/sftp.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
#
from posix.types cimport mode_t

from pylibsshext.includes.libssh cimport ssh_channel, ssh_session
from libc cimport stdint

from pylibsshext.includes.libssh cimport ssh_channel, ssh_session, ssh_string


cdef extern from "libssh/sftp.h" nogil:
Expand All @@ -30,6 +32,31 @@ cdef extern from "libssh/sftp.h" nogil:
pass
ctypedef sftp_file_struct * sftp_file

struct sftp_attributes_struct:
char *name
char *longname
stdint.uint32_t flags
stdint.uint8_t type
stdint.uint64_t size
stdint.uint32_t uid
stdint.uint32_t gid
char *owner
char *group
stdint.uint32_t permissions
stdint.uint64_t atime64
stdint.uint32_t atime
stdint.uint32_t atime_nseconds
stdint.uint64_t createtime
stdint.uint32_t createtime_nseconds
stdint.uint64_t mtime64
stdint.uint32_t mtime
stdint.uint32_t mtime_nseconds
ssh_string acl
stdint.uint32_t extended_count
ssh_string extended_type
ssh_string extended_data
ctypedef sftp_attributes_struct * sftp_attributes

cdef int SSH_FX_OK
cdef int SSH_FX_EOF
cdef int SSH_FX_NO_SUCH_FILE
Expand All @@ -55,5 +82,8 @@ cdef extern from "libssh/sftp.h" nogil:
ssize_t sftp_read(sftp_file file, const void *buf, size_t count)
int sftp_get_error(sftp_session sftp)

sftp_attributes sftp_stat(sftp_session session, const char *path)


cdef extern from "sys/stat.h" nogil:
cdef int S_IRWXU
60 changes: 40 additions & 20 deletions src/pylibsshext/sftp.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@
from posix.fcntl cimport O_CREAT, O_RDONLY, O_TRUNC, O_WRONLY

from cpython.bytes cimport PyBytes_AS_STRING
from cpython.mem cimport PyMem_Free, PyMem_Malloc

from pylibsshext.errors cimport LibsshSFTPException
from pylibsshext.session cimport get_libssh_session


SFTP_MAX_CHUNK = 32_768 # 32kB


MSG_MAP = {
sftp.SSH_FX_OK: "No error",
sftp.SSH_FX_EOF: "End-of-file encountered",
Expand Down Expand Up @@ -63,7 +67,7 @@ cdef class SFTP:
rf = sftp.sftp_open(self._libssh_sftp_session, remote_file_b, O_WRONLY | O_CREAT | O_TRUNC, sftp.S_IRWXU)
if rf is NULL:
raise LibsshSFTPException("Opening remote file [%s] for write failed with error [%s]" % (remote_file, self._get_sftp_error_str()))
buffer = f.read(1024)
buffer = f.read(SFTP_MAX_CHUNK)

while buffer != b"":
length = len(buffer)
Expand All @@ -76,38 +80,54 @@ cdef class SFTP:
self._get_sftp_error_str(),
)
)
buffer = f.read(1024)
buffer = f.read(SFTP_MAX_CHUNK)
sftp.sftp_close(rf)

def get(self, remote_file, local_file):
cdef sftp.sftp_file rf
cdef char read_buffer[1024]
cdef char *read_buffer = NULL
cdef sftp.sftp_attributes attrs

remote_file_b = remote_file
if isinstance(remote_file_b, unicode):
remote_file_b = remote_file.encode("utf-8")

attrs = sftp.sftp_stat(self._libssh_sftp_session, remote_file_b)
if attrs is NULL:
raise LibsshSFTPException("Failed to stat the remote file [%s]. Error: [%s]"
% (remote_file, self._get_sftp_error_str()))
file_size = attrs.size

rf = sftp.sftp_open(self._libssh_sftp_session, remote_file_b, O_RDONLY, sftp.S_IRWXU)
if rf is NULL:
raise LibsshSFTPException("Opening remote file [%s] for read failed with error [%s]" % (remote_file, self._get_sftp_error_str()))

with open(local_file, 'wb') as f:
while True:
file_data = sftp.sftp_read(rf, <void *>read_buffer, sizeof(char) * 1024)
if file_data == 0:
break
elif file_data < 0:
sftp.sftp_close(rf)
raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]"
% (remote_file, self._get_sftp_error_str()))

bytes_written = f.write(read_buffer[:file_data])
if bytes_written and file_data != bytes_written:
sftp.sftp_close(rf)
raise LibsshSFTPException("Number of bytes [%s] read from remote file [%s]"
" does not match number of bytes [%s] written to local file [%s]"
" due to error [%s]"
% (file_data, remote_file, bytes_written, local_file, self._get_sftp_error_str()))
try:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be better to convert the try-finally into a CM.

Something like

@contextlib.contextmanager
def allocated_buffer(file_size):
    buffer_size = min(SFTP_MAX_CHUNK, file_size)
    read_buffer = <char *>PyMem_Malloc(buffer_size)
    if read_buffer is NULL:
        raise LibsshSFTPException("Memory allocation error")

    try:
        yield read_buffer
    finally:
        if read_buffer is not NULL:
            PyMem_Free(read_buffer)

And then with open(local_file, 'wb') as f, allocated_buffer(file_size) as read_buffer: would allow you to dedent this entire block back and avoid excessive levels of nesting.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. I think this is a good idea. But I was not able to make it working from cpython for some reason. Probably I was doing something wrong as when I do this, all the tests using the get() method start crashing (without any good trace to see where and why).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Could you at least post those logs here for history?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can it be cython/cython#5963? It hasn't made it into any release AFAICS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be. The diff I used:

diff --git a/src/pylibsshext/sftp.pyx b/src/pylibsshext/sftp.pyx
index 7528eba..11f9306 100644
--- a/src/pylibsshext/sftp.pyx
+++ b/src/pylibsshext/sftp.pyx
@@ -15,6 +15,7 @@
 # License along with this library; if not, see file LICENSE.rst in this
 # repository.
 
+from contextlib import contextmanager
 from posix.fcntl cimport O_CREAT, O_RDONLY, O_TRUNC, O_WRONLY
 
 from cpython.bytes cimport PyBytes_AS_STRING
@@ -27,6 +28,19 @@ from pylibsshext.session cimport get_libssh_session
 SFTP_MAX_CHUNK = 32_768  # 32kB
 
 
+@contextmanager
+def allocated_buffer(buffer_size):
+    read_buffer = <char *>PyMem_Malloc(buffer_size)
+    if read_buffer is NULL:
+        raise LibsshSFTPException("Memory allocation error")
+
+    try:
+        yield read_buffer
+    finally:
+        if read_buffer is not NULL:
+            PyMem_Free(read_buffer)
+
+
 MSG_MAP = {
     sftp.SSH_FX_OK: "No error",
     sftp.SSH_FX_EOF: "End-of-file encountered",
@@ -85,7 +99,6 @@ cdef class SFTP:
 
     def get(self, remote_file, local_file):
         cdef sftp.sftp_file rf
-        cdef char *read_buffer = NULL
         cdef sftp.sftp_attributes attrs
 
         remote_file_b = remote_file
@@ -102,32 +115,25 @@ cdef class SFTP:
         if rf is NULL:
             raise LibsshSFTPException("Opening remote file [%s] for read failed with error [%s]" % (remote_file, self._get_sftp_error_str()))
 
-        try:
-            with open(local_file, 'wb') as f:
-                buffer_size = min(SFTP_MAX_CHUNK, file_size)
-                read_buffer = <char *>PyMem_Malloc(buffer_size)
-                if read_buffer is NULL:
-                    raise LibsshSFTPException("Memory allocation error")
-
-                while True:
-                    file_data = sftp.sftp_read(rf, <void *>read_buffer, sizeof(char) * buffer_size)
-                    if file_data == 0:
-                        break
-                    elif file_data < 0:
-                        sftp.sftp_close(rf)
-                        raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]"
-                                                  % (remote_file, self._get_sftp_error_str()))
-
-                    bytes_written = f.write(read_buffer[:file_data])
-                    if bytes_written and file_data != bytes_written:
-                        sftp.sftp_close(rf)
-                        raise LibsshSFTPException("Number of bytes [%s] read from remote file [%s]"
-                                                  " does not match number of bytes [%s] written to local file [%s]"
-                                                  " due to error [%s]"
-                                                  % (file_data, remote_file, bytes_written, local_file, self._get_sftp_error_str()))
-        finally:
-            if read_buffer is not NULL:
-                PyMem_Free(read_buffer)
+        buffer_size = min(SFTP_MAX_CHUNK, file_size)
+        with open(local_file, 'wb') as f, allocated_buffer(buffer_size) as read_buffer:
+
+            while True:
+                read_length = sftp.sftp_read(rf, <void *>read_buffer, sizeof(char) * buffer_size)
+                if read_length == 0:
+                    break
+                elif read_length < 0:
+                    sftp.sftp_close(rf)
+                    raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]"
+                                              % (remote_file, self._get_sftp_error_str()))
+
+                bytes_written = f.write(read_buffer[:read_length])
+                if bytes_written and read_length != bytes_written:
+                    sftp.sftp_close(rf)
+                    raise LibsshSFTPException("Number of bytes [%s] read from remote file [%s]"
+                                              " does not match number of bytes [%s] written to local file [%s]"
+                                              " due to error [%s]"
+                                              % (read_length, remote_file, bytes_written, local_file, self._get_sftp_error_str()))
         sftp.sftp_close(rf)
 
     def close(self):

And the test results (for only one failing test -- the other get tests fail the same way):

$ tox -e just-pytest -- -s tests/unit/sftp_test.py::test_get_existing[large-payload]
.pkg: _optional_hooks> python /usr/lib/python3.13/site-packages/pyproject_api/_backend.py True pep517_backend.hooks
.pkg: get_requires_for_build_sdist> python /usr/lib/python3.13/site-packages/pyproject_api/_backend.py True pep517_backend.hooks
.pkg: get_requires_for_build_wheel> python /usr/lib/python3.13/site-packages/pyproject_api/_backend.py True pep517_backend.hooks
.pkg: prepare_metadata_for_build_wheel> python /usr/lib/python3.13/site-packages/pyproject_api/_backend.py True pep517_backend.hooks
.pkg: build_sdist> python /usr/lib/python3.13/site-packages/pyproject_api/_backend.py True pep517_backend.hooks
just-pytest: install_package> python -I -m pip install --force-reinstall --no-deps /home/jjelen/devel/pylibssh/.tox/.tmp/package/45/ansible_pylibssh-1.2.3.dev140+g2cbd1b7.tar.gz
just-pytest: commands[0]> .tox/just-pytest/bin/python -m pytest --color=yes --no-cov -s 'tests/unit/sftp_test.py::test_get_existing[large-payload]'
==================================================================================================================================================== test session starts ====================================================================================================================================================
platform linux -- Python 3.13.2, pytest-8.3.5, pluggy-1.5.0
cachedir: .tox/just-pytest/.pytest_cache
rootdir: /home/jjelen/devel/pylibssh
configfile: pytest.ini
plugins: cov-6.0.0, xdist-3.6.1, forked-1.6.0
16 workers [1 item]       
debug2: load_server_config: filename /dev/null
debug2: load_server_config: done config len = 1
debug2: parse_server_config_depth: config /dev/null len 1
debug1: sshd version OpenSSH_9.9, OpenSSL 3.2.4 11 Feb 2025
debug1: private host key #0: ssh-ed25519 SHA256:Xiy9ToCvuDrDWWfaaDLnzChYEmwbXWrJeDzDvxGrF5A
debug1: setgroups() failed: Operation not permitted
debug1: rexec_argv[1]='-D'
debug1: rexec_argv[2]='-f'
debug1: rexec_argv[3]='/dev/null'
debug1: rexec_argv[4]='-o'
debug1: rexec_argv[5]='LogLevel=DEBUG3'
debug1: rexec_argv[6]='-o'
debug1: rexec_argv[7]='HostKey=/tmp/pytest-of-jjelen/pytest-14/popen-gw0/test_get_existing_large_payloa0/sshd/ssh_host_rsa_key'
debug1: rexec_argv[8]='-o'
debug1: rexec_argv[9]='PidFile=/tmp/pytest-of-jjelen/pytest-14/popen-gw0/test_get_existing_large_payloa0/sshd/sshd.pid'
debug1: rexec_argv[10]='-o'
debug1: rexec_argv[11]='UsePAM=yes'
debug1: rexec_argv[12]='-o'
debug1: rexec_argv[13]='PasswordAuthentication=no'
debug1: rexec_argv[14]='-o'
debug1: rexec_argv[15]='ChallengeResponseAuthentication=no'
debug1: rexec_argv[16]='-o'
debug1: rexec_argv[17]='GSSAPIAuthentication=no'
debug1: rexec_argv[18]='-o'
debug1: rexec_argv[19]='StrictModes=no'
debug1: rexec_argv[20]='-o'
debug1: rexec_argv[21]='PermitEmptyPasswords=yes'
debug1: rexec_argv[22]='-o'
debug1: rexec_argv[23]='PermitRootLogin=yes'
debug1: rexec_argv[24]='-o'
debug1: rexec_argv[25]='Protocol=2'
debug1: rexec_argv[26]='-o'
debug1: rexec_argv[27]='HostbasedAuthentication=no'
debug1: rexec_argv[28]='-o'
debug1: rexec_argv[29]='IgnoreUserKnownHosts=yes'
debug1: rexec_argv[30]='-o'
debug1: rexec_argv[31]='Port=37369'
debug1: rexec_argv[32]='-o'
debug1: rexec_argv[33]='ListenAddress=127.0.0.1'
debug1: rexec_argv[34]='-o'
debug1: rexec_argv[35]='AuthorizedKeysFile=/tmp/pytest-of-jjelen/pytest-14/popen-gw0/test_get_existing_large_payloa0/sshd/authorized_keys'
debug1: rexec_argv[36]='-o'
debug1: rexec_argv[37]='AcceptEnv=LANG LC_*'
debug1: rexec_argv[38]='-o'
debug1: rexec_argv[39]='Subsystem=sftp internal-sftp'
debug3: using /usr/libexec/openssh/sshd-session for re-exec
debug1: sshd version OpenSSH_9.9, OpenSSL 3.2.4 11 Feb 2025
debug3: recv_rexec_state: entering fd = 5
debug3: ssh_msg_recv entering
debug2: parse_hostkeys: privkey 0: ssh-ed25519
debug2: parse_hostkeys: pubkey 0: ssh-ed25519
debug3: recv_rexec_state: done
debug2: parse_server_config_depth: config rexec len 1
Warning: Permanently added '[127.0.0.1]:37369' (ED25519) to the list of known hosts.
debug1: sshd version OpenSSH_9.9, OpenSSL 3.2.4 11 Feb 2025
debug3: recv_rexec_state: entering fd = 5
debug3: ssh_msg_recv entering
debug2: parse_hostkeys: privkey 0: ssh-ed25519
debug2: parse_hostkeys: pubkey 0: ssh-ed25519
debug3: recv_rexec_state: done
debug2: parse_server_config_depth: config rexec len 1
Fatal Python error: Segmentation fault

Thread 0x00007fe86dba96c0 (most recent call first):
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 534 in read
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 567 in from_io
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 1160 in _thread_receiver
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 341 in run
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 411 in _perform_spawn

Current thread 0x00007fe87cc5ab80 (most recent call first):
  File "/home/jjelen/devel/pylibssh/tests/unit/sftp_test.py", line 100 in test_get_existing
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/python.py", line 159 in pytest_pyfunc_call
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_callers.py", line 103 in _multicall
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_hooks.py", line 513 in __call__
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/python.py", line 1627 in runtest
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/runner.py", line 174 in pytest_runtest_call
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_callers.py", line 103 in _multicall
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_hooks.py", line 513 in __call__
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/runner.py", line 242 in <lambda>
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/runner.py", line 341 in from_call
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/runner.py", line 241 in call_and_report
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/runner.py", line 132 in runtestprotocol
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/runner.py", line 113 in pytest_runtest_protocol
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_callers.py", line 103 in _multicall
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_hooks.py", line 513 in __call__
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/xdist/remote.py", line 195 in run_one_test
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/xdist/remote.py", line 174 in pytest_runtestloop
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_callers.py", line 103 in _multicall
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_hooks.py", line 513 in __call__
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/main.py", line 337 in _main
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/main.py", line 283 in wrap_session
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/_pytest/main.py", line 330 in pytest_cmdline_main
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_callers.py", line 103 in _multicall
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_manager.py", line 120 in _hookexec
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/pluggy/_hooks.py", line 513 in __call__
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/xdist/remote.py", line 393 in <module>
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 1291 in executetask
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 341 in run
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 411 in _perform_spawn
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 389 in integrate_as_primary_thread
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 1273 in serve
  File "/home/jjelen/devel/pylibssh/.tox/just-pytest/lib/python3.13/site-packages/execnet/gateway_base.py", line 1806 in serve
  File "<string>", line 8 in <module>
  File "<string>", line 1 in <module>

Extension modules: pylibsshext._libssh_version, pylibsshext.errors, pylibsshext.channel, pylibsshext.scp, pylibsshext.sftp, pylibsshext.session (total: 6)
[gw0] node down: Not properly terminated
F
replacing crashed worker gw0
collecting: 16/17 workers
========================================================================================================================================================= FAILURES ==========================================================================================================================================================
__________________________________________________________________________________________________________________________________________________ tests/unit/sftp_test.py __________________________________________________________________________________________________________________________________________________
[gw0] linux -- Python 3.13.2 /home/jjelen/devel/pylibssh/.tox/just-pytest/bin/python
worker 'gw0' crashed while running 'tests/unit/sftp_test.py::test_get_existing[large-payload]'
--------------------------------------------------------------------------------------------------------------------- generated xml file: /home/jjelen/devel/pylibssh/.test-results/pytest/results.xml ----------------------------------------------------------------------------------------------------------------------
=================================================================================================================================================== slowest 10 durations ====================================================================================================================================================
3.54s setup    tests/unit/sftp_test.py::test_get_existing[large-payload]

(1 durations < 0.005s hidden.  Use -vv to show these durations.)
================================================================================================================================================== short test summary info ==================================================================================================================================================
FAILED tests/unit/sftp_test.py::test_get_existing[large-payload]
===================================================================================================================================================== 1 failed in 4.65s =====================================================================================================================================================
just-pytest: exit 1 (4.83 seconds) /home/jjelen/devel/pylibssh> .tox/just-pytest/bin/python -m pytest --color=yes --no-cov -s 'tests/unit/sftp_test.py::test_get_existing[large-payload]' pid=1424782
.pkg: _exit> python /usr/lib/python3.13/site-packages/pyproject_api/_backend.py True pep517_backend.hooks
  just-pytest: FAIL code 1 (11.10=setup[6.27]+cmd[4.83] seconds)
  evaluation failed :( (11.14 seconds)

with open(local_file, 'wb') as f:
buffer_size = min(SFTP_MAX_CHUNK, file_size)
read_buffer = <char *>PyMem_Malloc(buffer_size)
if read_buffer is NULL:
raise LibsshSFTPException("Memory allocation error")

while True:
file_data = sftp.sftp_read(rf, <void *>read_buffer, sizeof(char) * buffer_size)
if file_data == 0:
break
elif file_data < 0:
sftp.sftp_close(rf)
raise LibsshSFTPException("Reading data from remote file [%s] failed with error [%s]"
% (remote_file, self._get_sftp_error_str()))

bytes_written = f.write(read_buffer[:file_data])
if bytes_written and file_data != bytes_written:
sftp.sftp_close(rf)
raise LibsshSFTPException("Number of bytes [%s] read from remote file [%s]"
" does not match number of bytes [%s] written to local file [%s]"
" due to error [%s]"
% (file_data, remote_file, bytes_written, local_file, self._get_sftp_error_str()))
finally:
if read_buffer is not NULL:
PyMem_Free(read_buffer)
sftp.sftp_close(rf)

def close(self):
Expand Down
9 changes: 6 additions & 3 deletions tests/unit/sftp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import pytest

from pylibsshext.sftp import SFTP_MAX_CHUNK


@pytest.fixture
def sftp_session(ssh_client_session):
Expand All @@ -21,16 +23,17 @@ def sftp_session(ssh_client_session):


@pytest.fixture(
params=(32, 1024 + 1),
params=(32, SFTP_MAX_CHUNK + 1),
ids=('small-payload', 'large-payload'),
)
def transmit_payload(request: pytest.FixtureRequest) -> bytes:
"""Generate binary test payloads of assorted sizes.

The choice 32 is arbitrary small value.

The choice 1024 + 1 is meant to be 1B larger than the chunk size used in
:file:`sftp.pyx` to make sure we excercise at least two rounds of reading/writing.
The choice SFTP_MAX_CHUNK + 1 (32kB + 1B) is meant to be 1B larger than the chunk
size used in :file:`sftp.pyx` to make sure we excercise at least two rounds of
reading/writing.
"""
payload_len = request.param
random_bytes = [ord(random.choice(string.printable)) for _ in range(payload_len)]
Expand Down
Loading