diff -Nru aioftp-0.18.1/aioftp/client.py aioftp-0.21.4/aioftp/client.py --- aioftp-0.18.1/aioftp/client.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/aioftp/client.py 2022-10-12 21:30:44.000000000 +0000 @@ -1,3 +1,4 @@ +import asyncio import calendar import collections import contextlib @@ -105,12 +106,21 @@ class BaseClient: - def __init__(self, *, socket_timeout=None, - read_speed_limit=None, write_speed_limit=None, - path_timeout=None, path_io_factory=pathio.PathIO, - encoding="utf-8", ssl=None, parse_list_line_custom=None, - passive_commands=("epsv", "pasv"), **siosocks_asyncio_kwargs): + def __init__(self, *, + socket_timeout=None, + connection_timeout=None, + read_speed_limit=None, + write_speed_limit=None, + path_timeout=None, + path_io_factory=pathio.PathIO, + encoding="utf-8", + ssl=None, + parse_list_line_custom=None, + parse_list_line_custom_first=True, + passive_commands=("epsv", "pasv"), + **siosocks_asyncio_kwargs): self.socket_timeout = socket_timeout + self.connection_timeout = connection_timeout self.throttle = StreamThrottle.from_limits( read_speed_limit, write_speed_limit, @@ -121,6 +131,7 @@ self.stream = None self.ssl = ssl self.parse_list_line_custom = parse_list_line_custom + self.parse_list_line_custom_first = parse_list_line_custom_first self._passive_commands = passive_commands self._open_connection = partial(open_connection, ssl=self.ssl, **siosocks_asyncio_kwargs) @@ -128,7 +139,10 @@ async def connect(self, host, port=DEFAULT_PORT): self.server_host = host self.server_port = port - reader, writer = await self._open_connection(host, port) + reader, writer = await asyncio.wait_for( + self._open_connection(host, port), + self.connection_timeout, + ) self.stream = ThrottleStreamIO( reader, writer, @@ -513,11 +527,14 @@ :rtype: (:py:class:`pathlib.PurePosixPath`, :py:class:`dict`) """ ex = [] - parsers = ( - self.parse_list_line_custom, + parsers = [ self.parse_list_line_unix, self.parse_list_line_windows, - ) + ] + if self.parse_list_line_custom_first: + parsers = [self.parse_list_line_custom] + parsers + else: + parsers = parsers + [self.parse_list_line_custom] for parser in parsers: if parser is None: continue @@ -557,6 +574,9 @@ :param socket_timeout: timeout for read operations :type socket_timeout: :py:class:`float`, :py:class:`int` or `None` + :param connection_timeout: timeout for connection + :type connection_timeout: :py:class:`float`, :py:class:`int` or `None` + :param read_speed_limit: download speed limit in bytes per second :type server_to_client_speed_limit: :py:class:`int` or `None` @@ -587,6 +607,9 @@ dictionary with fields "modify", "type", "size". For more information see sources. :type parse_list_line_custom: callable + :param parse_list_line_custom_first: Should be custom parser tried first + or last + :type parse_list_line_custom_first: :py:class:`bool` :param **siosocks_asyncio_kwargs: siosocks key-word only arguments """ async def connect(self, host, port=DEFAULT_PORT): @@ -702,9 +725,10 @@ """ :py:func:`asyncio.coroutine` - List all files and directories in "path". + List all files and directories in "path". If "path" is a file, + then result will be empty - :param path: directory or file path + :param path: directory :type path: :py:class:`str` or :py:class:`pathlib.PurePosixPath` :param recursive: list recursively @@ -771,11 +795,7 @@ else: raise StopAsyncIteration - try: - name, info = cls.parse_line(line) - except Exception: - continue - + name, info = cls.parse_line(line) stat = cls.path / name, info if info["type"] == "dir" and recursive: cls.directories.append(stat) diff -Nru aioftp-0.18.1/aioftp/common.py aioftp-0.21.4/aioftp/common.py --- aioftp-0.18.1/aioftp/common.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/aioftp/common.py 2022-10-12 21:30:44.000000000 +0000 @@ -473,13 +473,13 @@ :param name: name of throttle to acquire ("read" or "write") :type name: :py:class:`str` """ - waiters = [] + tasks = [] for throttle in self.throttles.values(): curr_throttle = getattr(throttle, name) if curr_throttle.limit: - waiters.append(curr_throttle.wait()) - if waiters: - await asyncio.wait(waiters) + tasks.append(asyncio.create_task(curr_throttle.wait())) + if tasks: + await asyncio.wait(tasks) def append(self, name, data, start): """ diff -Nru aioftp-0.18.1/aioftp/__init__.py aioftp-0.21.4/aioftp/__init__.py --- aioftp-0.18.1/aioftp/__init__.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/aioftp/__init__.py 2022-10-12 21:30:44.000000000 +0000 @@ -7,7 +7,7 @@ from .pathio import * from .server import * -__version__ = "0.18.1" +__version__ = "0.21.4" version = tuple(map(int, __version__.split("."))) __all__ = ( diff -Nru aioftp-0.18.1/aioftp/server.py aioftp-0.21.4/aioftp/server.py --- aioftp-0.18.1/aioftp/server.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/aioftp/server.py 2022-10-12 21:30:44.000000000 +0000 @@ -139,7 +139,7 @@ self.write_speed_limit_per_connection = \ write_speed_limit_per_connection - def get_permissions(self, path): + async def get_permissions(self, path): """ Return nearest parent permission for `path`. @@ -536,7 +536,9 @@ @functools.wraps(f) async def wrapper(cls, connection, rest, *args): real_path, virtual_path = cls.get_paths(connection, rest) - current_permission = connection.user.get_permissions(virtual_path) + current_permission = await connection.user.get_permissions( + virtual_path, + ) for permission in self.permissions: if not getattr(current_permission, permission): connection.response("550", "permission denied") @@ -619,6 +621,11 @@ connection in bytes per second :type write_speed_limit_per_connection: :py:class:`int` or :py:class:`None` + :param ipv4_pasv_forced_response_address: external IPv4 address for passive + connections + :type ipv4_pasv_forced_response_address: :py:class:`str` or + :py:class:`None` + :param data_ports: port numbers that are available for passive connections :type data_ports: :py:class:`collections.Iterable` or :py:class:`None` @@ -644,6 +651,7 @@ write_speed_limit=None, read_speed_limit_per_connection=None, write_speed_limit_per_connection=None, + ipv4_pasv_forced_response_address=None, data_ports=None, encoding="utf-8", ssl=None): @@ -653,6 +661,8 @@ self.wait_future_timeout = wait_future_timeout self.path_io_factory = pathio.PathIONursery(path_io_factory) self.path_timeout = path_timeout + self.ipv4_pasv_forced_response_address = \ + ipv4_pasv_forced_response_address if data_ports is not None: self.available_data_ports = asyncio.PriorityQueue() for data_port in data_ports: @@ -783,7 +793,7 @@ Shutdown the server and close all connections. """ self.server.close() - tasks = [self.server.wait_closed()] + tasks = [asyncio.create_task(self.server.wait_closed())] for connection in self.connections.values(): connection._dispatcher.cancel() tasks.append(connection._dispatcher) @@ -919,9 +929,9 @@ connection.path_io = self.path_io_factory(timeout=self.path_timeout, connection=connection) pending = { - self.greeting(connection, ""), - self.response_writer(stream, response_queue), - self.parse_command(stream), + asyncio.create_task(self.greeting(connection, "")), + asyncio.create_task(self.response_writer(stream, response_queue)), + asyncio.create_task(self.parse_command(stream)), } self.connections[key] = connection try: @@ -944,11 +954,15 @@ return # this is parse_command result elif isinstance(result, tuple): - pending.add(self.parse_command(stream)) + pending.add( + asyncio.create_task(self.parse_command(stream)) + ) cmd, rest = result f = self.commands_mapping.get(cmd) if f is not None: - pending.add(f(connection, rest)) + pending.add( + asyncio.create_task(f(connection, rest)) + ) if cmd not in ("retr", "stor", "appe"): connection.restart_offset = 0 else: @@ -963,9 +977,8 @@ tasks_to_wait = [] if not asyncio.get_running_loop().is_closed(): for task in pending | connection.extra_workers: - if isinstance(task, asyncio.Task): - task.cancel() - tasks_to_wait.append(task) + task.cancel() + tasks_to_wait.append(task) if connection.future.passive_server.done(): connection.passive_server.close() if self.available_data_ports is not None: @@ -977,13 +990,16 @@ if connection.acquired: self.available_connections.release() if connection.future.user.done(): - task = self.user_manager.notify_logout(connection.user) + task = asyncio.create_task( + self.user_manager.notify_logout(connection.user) + ) tasks_to_wait.append(task) self.connections.pop(key) if tasks_to_wait: await asyncio.wait(tasks_to_wait) - def get_paths(self, connection, path): + @staticmethod + def get_paths(connection, path): """ Return *real* and *virtual* paths, resolves ".." with "up" action. *Real* path is path for path_io, when *virtual* deals with @@ -1009,6 +1025,12 @@ resolved_virtual_path /= part base_path = connection.user.base_path real_path = base_path / resolved_virtual_path.relative_to("/") + # replace with `is_relative_to` check after 3.9+ requirements lands + try: + real_path.relative_to(base_path) + except ValueError: + real_path = base_path + resolved_virtual_path = pathlib.PurePosixPath("/") return real_path, resolved_virtual_path async def greeting(self, connection, rest): @@ -1128,8 +1150,11 @@ } async def build_mlsx_string(self, connection, path): - stats = await connection.path_io.stat(path) - facts = self._build_mlsx_facts_from_stats(stats) + if not await connection.path_io.exists(path): + facts = {} + else: + stats = await connection.path_io.stat(path) + facts = self._build_mlsx_facts_from_stats(stats) if await connection.path_io.is_file(path): facts["Type"] = "file" elif await connection.path_io.is_dir(path): @@ -1169,7 +1194,7 @@ real_path, virtual_path = self.get_paths(connection, rest) coro = mlsd_worker(self, connection, rest) - task = asyncio.ensure_future(coro) + task = asyncio.create_task(coro) connection.extra_workers.add(task) connection.response("150", "mlsd transfer started") return True @@ -1219,6 +1244,9 @@ del connection.data_connection async with stream: async for path in connection.path_io.list(real_path): + if not (await connection.path_io.exists(path)): + logger.warning("path %r does not exists", path) + continue s = await self.build_list_string(connection, path) b = (s + END_OF_LINE).encode(encoding=self.encoding) await stream.write(b) @@ -1227,7 +1255,7 @@ real_path, virtual_path = self.get_paths(connection, rest) coro = list_worker(self, connection, rest) - task = asyncio.ensure_future(coro) + task = asyncio.create_task(coro) connection.extra_workers.add(task) connection.response("150", "list transfer started") return True @@ -1305,7 +1333,7 @@ real_path, virtual_path = self.get_paths(connection, rest) if await connection.path_io.is_dir(real_path.parent): coro = stor_worker(self, connection, rest) - task = asyncio.ensure_future(coro) + task = asyncio.create_task(coro) connection.extra_workers.add(task) code, info = "150", "data transfer started" else: @@ -1342,7 +1370,7 @@ real_path, virtual_path = self.get_paths(connection, rest) coro = retr_worker(self, connection, rest) - task = asyncio.ensure_future(coro) + task = asyncio.create_task(coro) connection.extra_workers.add(task) connection.response("150", "data transfer started") return True @@ -1433,6 +1461,11 @@ for sock in connection.passive_server.sockets: if sock.family == socket.AF_INET: host, port = sock.getsockname() + # If the FTP server is behind NAT, the server needs to report + # its external IP instead of the internal IP so that the client + # is able to connect to the server. + if self.ipv4_pasv_forced_response_address: + host = self.ipv4_pasv_forced_response_address break else: connection.response("503", ["this server started in ipv6 mode"]) diff -Nru aioftp-0.18.1/debian/changelog aioftp-0.21.4/debian/changelog --- aioftp-0.18.1/debian/changelog 2022-05-27 23:46:40.000000000 +0000 +++ aioftp-0.21.4/debian/changelog 2023-01-16 08:06:13.000000000 +0000 @@ -1,3 +1,25 @@ +aioftp (0.21.4-1) unstable; urgency=medium + + * Team upload. + * New upstream version 0.21.4. Closes: #1026551 + + -- Ole Streicher Mon, 16 Jan 2023 09:06:13 +0100 + +aioftp (0.18.1-4) unstable; urgency=medium + + [ Debian Janitor ] + * Bump debhelper from old 12 to 13. + * Update standards version to 4.6.1, no changes needed. + + -- Jelmer Vernooij Fri, 28 Oct 2022 20:23:20 +0100 + +aioftp (0.18.1-3) unstable; urgency=medium + + [ Debian Janitor ] + * Apply multi-arch hints. + python-aioftp-doc: Add Multi-Arch: foreign. + + -- Jelmer Vernooij Fri, 14 Oct 2022 11:35:23 +0100 + aioftp (0.18.1-2) unstable; urgency=medium [ Debian Janitor ] diff -Nru aioftp-0.18.1/debian/control aioftp-0.21.4/debian/control --- aioftp-0.18.1/debian/control 2022-05-27 23:46:40.000000000 +0000 +++ aioftp-0.21.4/debian/control 2023-01-16 07:19:58.000000000 +0000 @@ -3,7 +3,7 @@ Priority: optional Maintainer: Debian Python Team Uploaders: Adam Cecile -Build-Depends: debhelper-compat (= 12), +Build-Depends: debhelper-compat (= 13), dh-sequence-sphinxdoc , python3-doc , dh-python, @@ -15,7 +15,7 @@ python3-pytest-cov , python3-trustme , python3-async-timeout , -Standards-Version: 4.5.0 +Standards-Version: 4.6.1 Homepage: https://github.com/aio-libs/aioftp Vcs-Browser: https://salsa.debian.org/python-team/packages/aioftp Vcs-Git: https://salsa.debian.org/python-team/packages/aioftp.git @@ -44,6 +44,7 @@ Architecture: all Section: doc Depends: ${sphinxdoc:Depends}, ${misc:Depends} +Multi-Arch: foreign Description: FTP client and server for asyncio (common documentation) Library implementing FTP protocol, both client and server for Python asyncio module. diff -Nru aioftp-0.18.1/doc-requirements.txt aioftp-0.21.4/doc-requirements.txt --- aioftp-0.18.1/doc-requirements.txt 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/doc-requirements.txt 2022-10-12 21:30:44.000000000 +0000 @@ -1,2 +1,3 @@ sphinx alabaster +docutils < 0.18.0 diff -Nru aioftp-0.18.1/docs/index.rst aioftp-0.21.4/docs/index.rst --- aioftp-0.18.1/docs/index.rst 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/docs/index.rst 2022-10-12 21:30:44.000000000 +0000 @@ -94,6 +94,18 @@ Client example +**WARNING** + +For all commands, which use some sort of «stats» or «listing», ``aioftp`` tries +at first ``MLSx``-family commands (since they have structured, machine readable +format for all platforms). But old/lazy/nasty servers do not implement this +commands. In this case ``aioftp`` tries a ``LIST`` command, which have no +standard format and can not be parsed in all cases. Take a look at +`FileZilla `_ +«directory listing» parser code. So, before creating new issue be sure this +is not your case (you can check it with logs). Anyway, you can provide your own +``LIST`` parser routine (see the client documentation). + .. code-block:: python import asyncio @@ -107,12 +119,15 @@ await client.download(path) - tasks = ( - get_mp3("server1.com", 21, "login", "password"), - get_mp3("server2.com", 21, "login", "password"), - get_mp3("server3.com", 21, "login", "password"), - ) - asyncio.run(asyncio.wait(tasks)) + async def main(): + tasks = [ + asyncio.create_task(get_mp3("server1.com", 21, "login", "password")), + asyncio.create_task(get_mp3("server2.com", 21, "login", "password")), + asyncio.create_task(get_mp3("server3.com", 21, "login", "password")), + ] + await asyncio.wait(tasks) + + asyncio.run(main()) Server example diff -Nru aioftp-0.18.1/.github/workflows/ci.yml aioftp-0.21.4/.github/workflows/ci.yml --- aioftp-0.18.1/.github/workflows/ci.yml 1970-01-01 00:00:00.000000000 +0000 +++ aioftp-0.21.4/.github/workflows/ci.yml 2022-10-12 21:30:44.000000000 +0000 @@ -0,0 +1,47 @@ +name: build +on: [push, pull_request] + +jobs: + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: '3.x' + - run: | + pip install flake8 + flake8 aioftp tests + + tests: + needs: lint + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.7', '3.8', '3.9', '3.10'] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - run: | + pip install -e ./[tests] + pytest + - uses: codecov/codecov-action@v2 + with: + fail_ci_if_error: true + verbose: true + + deploy: + needs: tests + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + - uses: casperdcl/deploy-pypi@v2 + with: + password: ${{ secrets.PYPI_TOKEN }} + build: true + skip_existing: true diff -Nru aioftp-0.18.1/history.rst aioftp-0.21.4/history.rst --- aioftp-0.18.1/history.rst 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/history.rst 2022-10-12 21:30:44.000000000 +0000 @@ -1,11 +1,55 @@ x.x.x (xx-xx-xxxx) ------------------ +0.21.4 (13-10-2022) +------------------- +- tests: use `pytest_asyncio` `strict` mode and proper decorations (#155) +- setup/tests: set low bound for version of `async-timeout` (#159) + +0.21.3 (15-07-2022) +------------------- +- server/`LIST`: prevent broken links are listed, but can't be used with `stat` +- server: make `User.get_permissions` async + +0.21.2 (22-04-2022) +------------------- +- tests: remove exception representation check + +0.21.1 (20-04-2022) +------------------- +- tests: replace more specific `ConnectionRefusedError` with `OSError` for compatibility with FreeBSD (#152) +Thanks to `AMDmi3 https://github.com/AMDmi3`_ + +0.21.0 (18-03-2022) +------------------ +- server: support PASV response with custom address (#150) +Thanks to `janneronkko https://github.com/janneronkko`_ + +0.20.1 (15-02-2022) +------------------ +- server: fix real directory resolve for windows (#147) +Thanks to `ported-pw https://github.com/ported-pw`_ + +0.20.0 (27-12-2021) +------------------ + +- add client argument to set priority of custom list parser (`parse_list_line_custom_first`) (#145) +- do not ignore failed parsing of list response (#144) +Thanks to `spolloni https://github.com/spolloni`_ + +0.19.0 (08-10-2021) +------------------ + +- add client connection timeout (#140) +- remove explicit coroutine passing to `asyncio.wait` (#134) +Thanks to `decaz `_ + 0.18.1 (03-10-2020) ------------------ - sync tests with new `siosocks` (#127) - some docs fixes +- log level changes 0.18.0 (03-09-2020) ------------------ diff -Nru aioftp-0.18.1/pytest.ini aioftp-0.21.4/pytest.ini --- aioftp-0.18.1/pytest.ini 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/pytest.ini 2022-10-12 21:30:44.000000000 +0000 @@ -1,7 +1,7 @@ [pytest] addopts = -x --durations=10 --cov-config=.coveragerc --cov=aioftp --cov-report=xml --cov-report=term --cov-report=term-missing testpaths = tests -color = yes log_format = %(asctime)s.%(msecs)03d %(name)-20s %(levelname)-8s %(filename)-15s %(lineno)-4d %(message)s log_date_format = %H:%M:%S log_level = DEBUG +asyncio_mode = strict diff -Nru aioftp-0.18.1/README.rst aioftp-0.21.4/README.rst --- aioftp-0.18.1/README.rst 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/README.rst 2022-10-12 21:30:44.000000000 +0000 @@ -6,9 +6,9 @@ aioftp ====== -.. image:: https://travis-ci.com/aio-libs/aioftp.svg?branch=master - :target: https://travis-ci.com/aio-libs/aioftp - :alt: Travis status for master branch +.. image:: https://github.com/aio-libs/aioftp/actions/workflows/ci.yml/badge.svg?branch=master + :target: https://github.com/aio-libs/aioftp/actions/workflows/ci.yml + :alt: Github actions ci for master branch .. image:: https://codecov.io/gh/aio-libs/aioftp/branch/master/graph/badge.svg :target: https://codecov.io/gh/aio-libs/aioftp @@ -110,6 +110,18 @@ Client example +**WARNING** + +For all commands, which use some sort of «stats» or «listing», ``aioftp`` tries +at first ``MLSx``-family commands (since they have structured, machine readable +format for all platforms). But old/lazy/nasty servers do not implement this +commands. In this case ``aioftp`` tries a ``LIST`` command, which have no +standard format and can not be parsed in all cases. Take a look at +`FileZilla `_ +«directory listing» parser code. So, before creating new issue be sure this +is not your case (you can check it with logs). Anyway, you can provide your own +``LIST`` parser routine (see the client documentation). + .. code-block:: python import asyncio @@ -123,12 +135,15 @@ await client.download(path) - tasks = ( - get_mp3("server1.com", 21, "login", "password"), - get_mp3("server2.com", 21, "login", "password"), - get_mp3("server3.com", 21, "login", "password"), - ) - asyncio.run(asyncio.wait(tasks)) + async def main(): + tasks = [ + asyncio.create_task(get_mp3("server1.com", 21, "login", "password")), + asyncio.create_task(get_mp3("server2.com", 21, "login", "password")), + asyncio.create_task(get_mp3("server3.com", 21, "login", "password")), + ] + await asyncio.wait(tasks) + + asyncio.run(main()) Server example diff -Nru aioftp-0.18.1/setup.cfg aioftp-0.21.4/setup.cfg --- aioftp-0.18.1/setup.cfg 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/setup.cfg 2022-10-12 21:30:44.000000000 +0000 @@ -26,5 +26,5 @@ pytest-asyncio pytest-cov trustme - async_timeout + async_timeout >= 4.0.0 siosocks diff -Nru aioftp-0.18.1/tests/conftest.py aioftp-0.21.4/tests/conftest.py --- aioftp-0.18.1/tests/conftest.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/tests/conftest.py 2022-10-12 21:30:44.000000000 +0000 @@ -10,6 +10,7 @@ from pathlib import Path import pytest +import pytest_asyncio import trustme from async_timeout import timeout @@ -115,7 +116,7 @@ return False async def __aenter__(self): - self.timeout.__enter__() + await self.timeout.__aenter__() await self.server.start(host=self.host) if self.connected: await self.client.connect(self.server.server_host, @@ -129,7 +130,7 @@ await self.client.quit() self.client.close() await self.server.close() - self.timeout.__exit__(*exc_info) + await self.timeout.__aexit__(*exc_info) return Factory @@ -191,8 +192,8 @@ yield sleeper -@pytest.fixture(params=[("127.0.0.1", socket.AF_INET), - ("::1", socket.AF_INET6)]) +@pytest_asyncio.fixture(params=[("127.0.0.1", socket.AF_INET), + ("::1", socket.AF_INET6)]) async def socks(request, unused_tcp_port): handler = functools.partial( socks_server_handler, diff -Nru aioftp-0.18.1/tests/test_connection.py aioftp-0.21.4/tests/test_connection.py --- aioftp-0.18.1/tests/test_connection.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/tests/test_connection.py 2022-10-12 21:30:44.000000000 +0000 @@ -1,4 +1,5 @@ import asyncio +import ipaddress import pytest @@ -119,6 +120,39 @@ assert pair.server.available_data_ports.qsize() == 0 +@pytest.mark.asyncio +async def test_pasv_connection_pasv_forced_response_address(pair_factory, + Server, + unused_tcp_port): + def ipv4_used(): + try: + ipaddress.IPv4Address(pair.host) + return True + except ValueError: + return False + + async with pair_factory( + server=Server(ipv4_pasv_forced_response_address='127.0.0.2'), + ) as pair: + assert pair.server.ipv4_pasv_forced_response_address == '127.0.0.2' + + if ipv4_used(): + # The connection fails here because the server starts to listen for + # the passive connections on the host (IPv4 address) that is used + # by the control channel. In reality, if the server is behind NAT, + # the server is reached with the defined external IPv4 address, + # i.e. we can check that the connection to + # pair.server.ipv4_pasv_forced_response_address failed to know that + # the server returned correct external IP + with pytest.raises(OSError): + await pair.client.get_passive_connection(commands=['pasv']) + + # With epsv the connection should open as that does not use the + # external IPv4 address but just tells the client the port to connect + # to + await pair.client.get_passive_connection(commands=['epsv']) + + @pytest.mark.parametrize("method", ["epsv", "pasv"]) @pytest.mark.asyncio async def test_pasv_connection_no_free_port(pair_factory, Server, diff -Nru aioftp-0.18.1/tests/test_extra.py aioftp-0.21.4/tests/test_extra.py --- aioftp-0.18.1/tests/test_extra.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/tests/test_extra.py 2022-10-12 21:30:44.000000000 +0000 @@ -31,7 +31,7 @@ @pytest.mark.asyncio async def test_no_server(unused_tcp_port): - with pytest.raises(ConnectionRefusedError): + with pytest.raises(OSError): async with aioftp.Client.context("127.0.0.1", unused_tcp_port): pass diff -Nru aioftp-0.18.1/tests/test_file.py aioftp-0.21.4/tests/test_file.py --- aioftp-0.18.1/tests/test_file.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/tests/test_file.py 2022-10-12 21:30:44.000000000 +0000 @@ -26,6 +26,14 @@ @pytest.mark.asyncio +async def test_mlsd_file(pair_factory): + async with pair_factory() as pair: + await pair.make_server_files("foo/bar.txt") + result = await pair.client.list("foo/bar.txt") + assert len(result) == 0 + + +@pytest.mark.asyncio async def test_file_download(pair_factory): async with pair_factory() as pair: await pair.make_server_files("foo", size=1, atom=b"foobar") diff -Nru aioftp-0.18.1/tests/test_list_fallback.py aioftp-0.21.4/tests/test_list_fallback.py --- aioftp-0.18.1/tests/test_list_fallback.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/tests/test_list_fallback.py 2022-10-12 21:30:44.000000000 +0000 @@ -26,6 +26,8 @@ assert len(files) == 1 assert path == pathlib.PurePosixPath("bar/foo") assert stat["type"] == "file" + result = await pair.client.list("bar/foo") + assert len(result) == 0 async def implemented_badly(connection, rest): @@ -108,6 +110,33 @@ pair.server.commands_mapping["mlst"] = not_implemented pair.server.commands_mapping["mlsd"] = not_implemented pair.server.build_list_string = builder + await pair.client.make_directory("bar") + (path, stat), *_ = files = await pair.client.list() + assert len(files) == 1 + assert path == pathlib.PurePosixPath("bar") + assert stat == meta + + +@pytest.mark.asyncio +async def test_client_list_override_with_custom_last(pair_factory, Client): + meta = {"type": "file", "works": True} + + def parser(b): + import pickle + return pickle.loads(bytes.fromhex(b.decode().rstrip("\r\n"))) + + async def builder(_, path): + import pickle + return pickle.dumps((path, meta)).hex() + + client = Client( + parse_list_line_custom=parser, + parse_list_line_custom_first=False, + ) + async with pair_factory(client) as pair: + pair.server.commands_mapping["mlst"] = not_implemented + pair.server.commands_mapping["mlsd"] = not_implemented + pair.server.build_list_string = builder await pair.client.make_directory("bar") (path, stat), *_ = files = await pair.client.list() assert len(files) == 1 diff -Nru aioftp-0.18.1/tests/test_simple_functions.py aioftp-0.21.4/tests/test_simple_functions.py --- aioftp-0.18.1/tests/test_simple_functions.py 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/tests/test_simple_functions.py 2022-10-12 21:30:44.000000000 +0000 @@ -230,3 +230,17 @@ b = aioftp.Server.build_list_mtime assert b(now, now) == "Jan 1 00:00" assert b(past, now) == "Jan 1 2001" + + +def test_get_paths_windows_traverse(): + base_path = pathlib.PureWindowsPath("C:\\ftp") + user = aioftp.User() + user.base_path = base_path + connection = aioftp.Connection(current_directory=base_path, user=user) + virtual_path = pathlib.PurePosixPath("/foo/C:\\windows") + real_path, resolved_virtual_path = aioftp.Server.get_paths( + connection, + virtual_path, + ) + assert real_path == base_path + assert resolved_virtual_path == pathlib.PurePosixPath("/") diff -Nru aioftp-0.18.1/.travis.yml aioftp-0.21.4/.travis.yml --- aioftp-0.18.1/.travis.yml 2020-10-03 11:41:59.000000000 +0000 +++ aioftp-0.21.4/.travis.yml 1970-01-01 00:00:00.000000000 +0000 @@ -1,65 +0,0 @@ -dist: xenial -language: python -python: 3.7 -services: - - docker -branches: - only: - - master -stages: - - lint - - test - - deploy -matrix: - include: - - name: lint - stage: lint - script: - - > - docker run --rm -v $(pwd):/source -w /source python bash -c " - pip install flake8; - flake8 aioftp tests" - - name: cpython 3.7 - stage: test - before_install: - - echo '{"ipv6":true,"fixed-cidr-v6":"2001:db8:1::/64"}' | sudo tee /etc/docker/daemon.json - - sudo systemctl restart docker - install: - - pip install codecov - script: - - > - docker run --rm -v $(pwd):/source -w /source python:3.7 bash -c " - pip install -e ./[tests]; - pytest; - rm -rf build/ *.egg-info" - after_success: - - codecov - - name: cpython 3.8 - stage: test - before_install: - - echo '{"ipv6":true,"fixed-cidr-v6":"2001:db8:1::/64"}' | sudo tee /etc/docker/daemon.json - - sudo systemctl restart docker - install: - - pip install codecov - script: - - > - docker run --rm -v $(pwd):/source -w /source python:3.8 bash -c " - pip install -e ./[tests]; - pytest; - rm -rf build/ *.egg-info" - after_success: - - codecov - - name: deploy - stage: deploy - script: - - echo "deploy" - deploy: - provider: pypi - username: __token__ - password: - secure: "k2+VW3e+JiFq1KaH1kKPb9n4sM790JIFYxwzmLUIq9nRI35A7FkinWmIa+lz3QHfGOi4HjNqKJospDY1vez0oXKQpeb4QdhTw8FRs+9b+X1Yff48hkA/GEiXiVCiJ6KS26qnUFEqU6xGdjamrdZ8G0e43Ezo/9a03X+Zi2/GIb+g2fWw5c41i87lb0f4APKmL7nfMGkIqHzOBdpPV6UYxIuOAlc1UCpiSoY94PTw8+w+XJKDD2h6+g3okCXXR6Z+iX6NvnGjbIBZ3L574tn8+XvlyhduWuiuArmmWQj5kiG0JH+KYRU/NhdF0vCGib1EfP1UZLsrZ3T1n0YhCHGcUKEFC98pERZdD7i3vkipKHFxaPbu7IBALsCtS/NuzBWk6JSp7uvV1DPX/98gZz3XOrMMbu5sI12LfiwStPhyKFec4NNrbR+DY/wRMvhY2dMXNQK1Jn5/452DqbrAqM5gJH9ifR2QLrwYSdyzzCNZzH/tTuxXrStk3F8oLIU4GOSI0sGtRADgBHZgASXlLg/2Wjhy5K2jb3lgXIBjzA3y2SVomcehVXQ3MyYEEHH7789j5AdwuTaK1uagpeVABDaPqmPvksFphFOPpQLQkS7ROooeRMut8NfqQUYTmek9xHQ2Tx6x/KuEI2W0AKZWpQ9SoXpKw0as+yVdnvXqWuNgv0s=" - distributions: sdist bdist_wheel - skip_existing: true - cleanup: false - on: - branch: master