├── .github └── workflows │ └── ci-cd.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── _static │ ├── logo.ico │ └── logo.png ├── client_api.rst ├── client_tutorial.rst ├── common_api.rst ├── conf.py ├── developer_tutorial.rst ├── index.rst ├── make.bat ├── path_io_api.rst ├── requirements.txt ├── server_api.rst └── server_tutorial.rst ├── ftpbench.py ├── history.rst ├── license.txt ├── pyproject.toml ├── src └── aioftp │ ├── __init__.py │ ├── __main__.py │ ├── client.py │ ├── common.py │ ├── errors.py │ ├── pathio.py │ └── server.py ├── tests ├── conftest.py ├── test_abort.py ├── test_client_side_socks.py ├── test_connection.py ├── test_corner_cases.py ├── test_current_directory.py ├── test_directory_actions.py ├── test_extra.py ├── test_file.py ├── test_list_fallback.py ├── test_login.py ├── test_maximum_connections.py ├── test_passive.py ├── test_pathio.py ├── test_permissions.py ├── test_restart.py ├── test_simple_functions.py ├── test_throttle.py ├── test_tls.py └── test_user.py └── uv.lock /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: ci/cd 2 | on: [push, pull_request] 3 | env: 4 | lowest_python_version: 3.9 5 | python_package_distributions: python-package-distributions 6 | 7 | jobs: 8 | 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: ${{ env.lowest_python_version }} 16 | - run: python -m pip install -e ./[dev] 17 | - run: python -m pre_commit run -a 18 | 19 | test: 20 | needs: lint 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-python@v2 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - run: python -m pip install -e ./[dev] 31 | - run: python -m pytest 32 | - uses: codecov/codecov-action@v4 33 | with: 34 | fail_ci_if_error: true 35 | verbose: true 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | 38 | build: 39 | needs: test 40 | runs-on: ubuntu-latest 41 | if: github.ref_type == 'tag' 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-python@v2 45 | with: 46 | python-version: ${{ env.lowest_python_version }} 47 | - run: python -m pip install build 48 | - run: python -m build 49 | - uses: actions/upload-artifact@v4 50 | with: 51 | name: ${{ env.python_package_distributions }} 52 | path: dist/* 53 | if-no-files-found: error 54 | retention-days: 14 55 | 56 | publish: 57 | needs: build 58 | runs-on: ubuntu-latest 59 | if: github.ref_type == 'tag' 60 | permissions: 61 | id-token: write # IMPORTANT: mandatory for trusted publishing 62 | environment: 63 | name: pypi 64 | url: https://pypi.org/project/aioftp 65 | steps: 66 | - uses: actions/download-artifact@v4 67 | with: 68 | name: ${{ env.python_package_distributions }} 69 | path: dist 70 | - uses: pypa/gh-action-pypi-publish@release/v1 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | /build/ 3 | /dist/ 4 | /.coverage 5 | __pycache__ 6 | *.pyc 7 | *.egg-info 8 | .idea 9 | /.python-version 10 | /.eggs 11 | /tags 12 | /*.py 13 | .vscode 14 | .mypy_cache 15 | .pytest_cache 16 | .ruff_cache 17 | coverage.xml 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | 4 | .python-linters: &python-linters 5 | pass_filenames: false 6 | fail_fast: true 7 | language: system 8 | types: [python] 9 | 10 | repos: 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.5.0 13 | hooks: 14 | - id: check-ast 15 | fail_fast: true 16 | - id: trailing-whitespace 17 | - id: check-toml 18 | fail_fast: true 19 | - id: end-of-file-fixer 20 | fail_fast: true 21 | 22 | - repo: https://github.com/asottile/add-trailing-comma 23 | rev: v3.1.0 24 | hooks: 25 | - id: add-trailing-comma 26 | fail_fast: true 27 | 28 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 29 | rev: v2.14.0 30 | hooks: 31 | - id: pretty-format-yaml 32 | fail_fast: true 33 | args: 34 | - --autofix 35 | - --preserve-quotes 36 | - --indent=2 37 | 38 | - repo: local 39 | hooks: 40 | - <<: *python-linters 41 | id: ruff format 42 | name: Format with ruff 43 | entry: ruff 44 | args: ["format", "."] 45 | 46 | - <<: *python-linters 47 | id: ruff 48 | name: Check with ruff 49 | entry: ruff 50 | args: ["check", "--fix", "."] 51 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.11" 7 | 8 | # Build from the docs/ directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | 12 | # Explicitly set the version of Python and its requirements 13 | python: 14 | install: 15 | - requirements: docs/requirements.txt 16 | - method: pip 17 | path: . 18 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include license.txt 3 | include history.rst 4 | recursive-include tests * 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. aioftp documentation master file, created by 2 | sphinx-quickstart on Fri Apr 17 16:21:03 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | aioftp 7 | ====== 8 | 9 | .. image:: https://github.com/aio-libs/aioftp/actions/workflows/ci-cd.yml/badge.svg?branch=master 10 | :target: https://github.com/aio-libs/aioftp/actions/workflows/ci-cd.yml 11 | :alt: Github actions ci-cd for master branch 12 | 13 | .. image:: https://codecov.io/gh/aio-libs/aioftp/branch/master/graph/badge.svg 14 | :target: https://codecov.io/gh/aio-libs/aioftp 15 | 16 | .. image:: https://img.shields.io/pypi/v/aioftp.svg 17 | :target: https://pypi.python.org/pypi/aioftp 18 | 19 | .. image:: https://img.shields.io/pypi/pyversions/aioftp.svg 20 | :target: https://pypi.python.org/pypi/aioftp 21 | 22 | .. image:: https://pepy.tech/badge/aioftp/month 23 | :target: https://pypi.python.org/pypi/aioftp 24 | 25 | ftp client/server for asyncio (https://aioftp.rtfd.io) 26 | 27 | .. _GitHub: https://github.com/aio-libs/aioftp 28 | 29 | Features 30 | -------- 31 | 32 | - Simple. 33 | - Extensible. 34 | - Client socks proxy via `siosocks `_ 35 | (`pip install aioftp[socks]`). 36 | 37 | Goals 38 | ----- 39 | 40 | - Minimum usable core. 41 | - Do not use deprecated or overridden commands and features (if possible). 42 | - Very high level api. 43 | 44 | Client use this commands: USER, PASS, ACCT, PWD, CWD, CDUP, MKD, RMD, MLSD, 45 | MLST, RNFR, RNTO, DELE, STOR, APPE, RETR, TYPE, PASV, ABOR, QUIT, REST, LIST 46 | (as fallback) 47 | 48 | Server support this commands: USER, PASS, QUIT, PWD, CWD, CDUP, MKD, RMD, MLSD, 49 | LIST (but it's not recommended to use it, cause it has no standard format), 50 | MLST, RNFR, RNTO, DELE, STOR, RETR, TYPE ("I" and "A"), PASV, ABOR, APPE, REST 51 | 52 | This subsets are enough for 99% of tasks, but if you need something, then you 53 | can easily extend current set of commands. 54 | 55 | Server benchmark 56 | ---------------- 57 | 58 | Compared with `pyftpdlib `_ and 59 | checked with its ftpbench script. 60 | 61 | aioftp 0.8.0 62 | 63 | :: 64 | 65 | STOR (client -> server) 284.95 MB/sec 66 | RETR (server -> client) 408.44 MB/sec 67 | 200 concurrent clients (connect, login) 0.18 secs 68 | STOR (1 file with 200 idle clients) 287.52 MB/sec 69 | RETR (1 file with 200 idle clients) 382.05 MB/sec 70 | 200 concurrent clients (RETR 10.0M file) 13.33 secs 71 | 200 concurrent clients (STOR 10.0M file) 12.56 secs 72 | 200 concurrent clients (QUIT) 0.03 secs 73 | 74 | aioftp 0.21.4 (python 3.11.2) 75 | 76 | :: 77 | 78 | STOR (client -> server) 280.17 MB/sec 79 | RETR (server -> client) 399.23 MB/sec 80 | 200 concurrent clients (connect, login) 0.22 secs 81 | STOR (1 file with 200 idle clients) 248.46 MB/sec 82 | RETR (1 file with 200 idle clients) 362.43 MB/sec 83 | 200 concurrent clients (RETR 10.0M file) 5.41 secs 84 | 200 concurrent clients (STOR 10.0M file) 2.04 secs 85 | 200 concurrent clients (QUIT) 0.04 secs 86 | 87 | pyftpdlib 1.5.2 88 | 89 | :: 90 | 91 | STOR (client -> server) 1235.56 MB/sec 92 | RETR (server -> client) 3960.21 MB/sec 93 | 200 concurrent clients (connect, login) 0.06 secs 94 | STOR (1 file with 200 idle clients) 1208.58 MB/sec 95 | RETR (1 file with 200 idle clients) 3496.03 MB/sec 96 | 200 concurrent clients (RETR 10.0M file) 0.55 secs 97 | 200 concurrent clients (STOR 10.0M file) 1.46 secs 98 | 200 concurrent clients (QUIT) 0.02 secs 99 | 100 | Dependencies 101 | ------------ 102 | 103 | - Python 3.9+ 104 | 105 | 0.13.0 is the last version which supports python 3.5.3+ 106 | 107 | 0.16.1 is the last version which supports python 3.6+ 108 | 109 | 0.21.4 is the last version which supports python 3.7+ 110 | 111 | 0.22.3 is the last version which supports python 3.8+ 112 | 113 | License 114 | ------- 115 | 116 | aioftp is offered under the Apache 2 license. 117 | 118 | Library installation 119 | -------------------- 120 | 121 | :: 122 | 123 | pip install aioftp 124 | 125 | Getting started 126 | --------------- 127 | 128 | Client example 129 | 130 | **WARNING** 131 | 132 | For all commands, which use some sort of «stats» or «listing», ``aioftp`` tries 133 | at first ``MLSx``-family commands (since they have structured, machine readable 134 | format for all platforms). But old/lazy/nasty servers do not implement this 135 | commands. In this case ``aioftp`` tries a ``LIST`` command, which have no 136 | standard format and can not be parsed in all cases. Take a look at 137 | `FileZilla `_ 138 | «directory listing» parser code. So, before creating new issue be sure this 139 | is not your case (you can check it with logs). Anyway, you can provide your own 140 | ``LIST`` parser routine (see the client documentation). 141 | 142 | .. code-block:: python 143 | 144 | import asyncio 145 | import aioftp 146 | 147 | 148 | async def get_mp3(host, port, login, password): 149 | async with aioftp.Client.context(host, port, login, password) as client: 150 | for path, info in (await client.list(recursive=True)): 151 | if info["type"] == "file" and path.suffix == ".mp3": 152 | await client.download(path) 153 | 154 | 155 | async def main(): 156 | tasks = [ 157 | asyncio.create_task(get_mp3("server1.com", 21, "login", "password")), 158 | asyncio.create_task(get_mp3("server2.com", 21, "login", "password")), 159 | asyncio.create_task(get_mp3("server3.com", 21, "login", "password")), 160 | ] 161 | await asyncio.wait(tasks) 162 | 163 | asyncio.run(main()) 164 | 165 | Server example 166 | 167 | .. code-block:: python 168 | 169 | import asyncio 170 | import aioftp 171 | 172 | 173 | async def main(): 174 | server = aioftp.Server([user], path_io_factory=path_io_factory) 175 | await server.run() 176 | 177 | asyncio.run(main()) 178 | 179 | Or just use simple server 180 | 181 | .. code-block:: shell 182 | 183 | python -m aioftp --help 184 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/aioftp.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aioftp.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/aioftp" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aioftp" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/_static/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aioftp/30b8764aad0c8f20c0962789fc1bebe5bd346d14/docs/_static/logo.ico -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aioftp/30b8764aad0c8f20c0962789fc1bebe5bd346d14/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/client_api.rst: -------------------------------------------------------------------------------- 1 | .. client_api: 2 | 3 | Client API 4 | ========== 5 | 6 | .. autoclass:: aioftp.Client 7 | :members: 8 | :undoc-members: 9 | :show-inheritance: 10 | :inherited-members: 11 | 12 | .. autoclass:: aioftp.DataConnectionThrottleStreamIO 13 | :members: 14 | :show-inheritance: 15 | 16 | .. autoclass:: aioftp.Code 17 | :members: 18 | :show-inheritance: 19 | 20 | .. autoclass:: aioftp.StatusCodeError 21 | -------------------------------------------------------------------------------- /docs/client_tutorial.rst: -------------------------------------------------------------------------------- 1 | Client tutorial 2 | =============== 3 | 4 | In 95% cases it is enough to use a little more than 10 client coroutines. So, 5 | lets start! 6 | 7 | Connecting to server 8 | -------------------- 9 | 10 | Firstly you should create :class:`aioftp.Client` instance and connect to host 11 | 12 | :py:meth:`aioftp.Client.connect` 13 | 14 | :py:meth:`aioftp.Client.login` 15 | 16 | :: 17 | 18 | >>> client = aioftp.Client() 19 | >>> await client.connect("ftp.server.com") 20 | >>> await client.login("user", "pass") 21 | 22 | Or just use :class:`aioftp.context` async context, which will connect, 23 | login and quit automatically 24 | 25 | :: 26 | 27 | >>> async with aioftp.Client.context("ftp.server.com", user="user", password="pass") as client: 28 | ... # do 29 | 30 | Download and upload paths 31 | ------------------------- 32 | 33 | :py:meth:`aioftp.Client.upload` and :py:meth:`aioftp.Client.download` 34 | coroutines are pretty similar, except data flow direction. You can 35 | upload/download file or directory. There is "source" and "destination". When 36 | you does not specify "destination", then current working directory will be 37 | used as destination. 38 | 39 | Lets upload some file to current directory 40 | :: 41 | 42 | >>> await client.upload("test.py") 43 | 44 | If you want specify new name, or different path to uploading/downloading path 45 | you should use "write_into" argument, which works for directory as well 46 | :: 47 | 48 | >>> await client.upload("test.py", "tmp/test.py", write_into=True) 49 | >>> await client.upload("folder1", "folder2", write_into=True) 50 | 51 | After that you get 52 | :: 53 | 54 | tmp/test.py 55 | folder2/*content of folder1* 56 | 57 | If you will not use "write_into", you will get something you probably did not 58 | expect 59 | :: 60 | 61 | tmp/test.py/test.py 62 | folder2/folder1/*content of folder1* 63 | 64 | Or you can upload path as is and then rename it 65 | (:py:meth:`aioftp.Client.rename`) 66 | 67 | Downloading is pretty same 68 | :: 69 | 70 | >>> await client.download("tmp/test.py", "foo.py", write_into=True) 71 | >>> await client.download("folder2") 72 | 73 | Listing paths 74 | ------------- 75 | 76 | For listing paths you should use :py:meth:`aioftp.Client.list` coroutine, which 77 | can list paths recursively and produce a :py:class:`list` and can be used 78 | with `async for` 79 | 80 | :: 81 | 82 | >>> await client.list("/") 83 | [(PosixPath('/.logs'), {'unix.mode': '0755', 'unique': '801g4804045', ... 84 | 85 | :: 86 | 87 | >>> await client.list("/", recursive=True) 88 | [(PosixPath('/.logs'), {'unix.mode': '0755', 'unique': '801g4804045', ... 89 | 90 | :: 91 | 92 | >>> async for path, info in client.list("/", recursive=True): 93 | ... print(path) 94 | (PosixPath('/.logs'), {'unix.mode': '0755', 'unique': '801g4804045', ... 95 | 96 | If you ommit path argument, result will be list for current working directory 97 | 98 | :: 99 | 100 | >>> await c.list() 101 | [(PosixPath('test.py'), {'unique': '801g480a508', 'size': '3102', ... 102 | 103 | In case of `async for` be careful, since asynchronous variation of list is lazy. 104 | It means that **you can't interact with server until you leave `async for` block.** 105 | If you need list and interact with server you should use eager version of list: 106 | 107 | :: 108 | 109 | >>> for path, info in (await client.list()): 110 | ... await client.download(path, path.name) 111 | 112 | If you want to mix lazy `list` and client interaction, you can create two client 113 | connections to server: 114 | 115 | :: 116 | 117 | >>> async for path, info in client1.list(): 118 | ... await client2.download(path, path.name) 119 | 120 | WARNING 121 | ^^^^^^^ 122 | 123 | :py:meth:`aioftp.Client.list` in general use `MLSD` command, but some nasty 124 | servers does not support this command. Then client will try to use `LIST` 125 | command, and parse server response. For proper work of 126 | :py:meth:`datetime.datetime.strptime` (in part of parsing month abbreviation) 127 | locale should be setted to "C". For this reason if you use multithreaded app, 128 | and use some locale-dependent stuff, you should use 129 | :py:meth:`aioftp.setlocale` context manager when you dealing with locale in 130 | another thread. 131 | **since 0.8.1** 132 | If fallback `LIST` parser can't parse line, then this line will be ignored, so 133 | fallback `LIST` implementation will never raise exception. 134 | 135 | Getting path stats 136 | ------------------ 137 | 138 | When you need get some path stats you should use :py:meth:`aioftp.Client.stat` 139 | 140 | :: 141 | 142 | >>> await client.stat("tmp2.py") 143 | {'size': '909', 'create': '1445437246.4320722', 'type': 'file', ... 144 | >>> await client.stat(".git") 145 | {'create': '1445435702.6441028', 'type': 'dir', 'size': '4096', ... 146 | 147 | If you need just to check path for is it file, directory or exists you can use 148 | 149 | :py:meth:`aioftp.Client.is_file` 150 | 151 | :py:meth:`aioftp.Client.is_dir` 152 | 153 | :py:meth:`aioftp.Client.exists` 154 | 155 | :: 156 | 157 | >>> await client.is_file("/public_html") 158 | False 159 | >>> await client.is_dir("/public_html") 160 | True 161 | >>> await client.is_file("test.py") 162 | True 163 | >>> await client.exists("test.py") 164 | True 165 | >>> await client.exists("naked-guido.png") 166 | False 167 | 168 | WARNING 169 | ^^^^^^^ 170 | 171 | :py:meth:`aioftp.Client.stat` in general use `MLST` command, but some nasty 172 | servers does not support this command. Then client will try to use `LIST` 173 | command, and parse server response. For proper work of 174 | :py:meth:`datetime.datetime.strptime` (in part of parsing month abbreviation) 175 | locale should be setted to "C". For this reason if you use multithreaded app, 176 | and use some locale-dependent stuff, you should use 177 | :py:meth:`aioftp.setlocale` context manager when you dealing with locale in 178 | another thread. 179 | **since 0.8.1** 180 | If fallback `LIST` parser can't parse line, then this line will be ignored, so 181 | fallback `LIST` implementation will never raise exception. But if requested 182 | path line can't be parsed, then :py:meth:`aioftp.Client.stat` method will 183 | raise `path does not exists`. 184 | 185 | 186 | Remove path 187 | ----------- 188 | 189 | For removing paths you have universal coroutine :py:meth:`aioftp.Client.remove` 190 | which can remove file or directory recursive. So, you don't need to do borring 191 | checks. 192 | 193 | :: 194 | 195 | >>> await client.remove("tmp.py") 196 | >>> await client.remove("folder1") 197 | 198 | Dealing with directories 199 | ------------------------ 200 | 201 | Directories coroutines are pretty simple. 202 | 203 | :py:meth:`aioftp.Client.get_current_directory` 204 | 205 | :py:meth:`aioftp.Client.change_directory` 206 | 207 | :py:meth:`aioftp.Client.make_directory` 208 | 209 | :: 210 | 211 | >>> await client.get_current_directory() 212 | PosixPath('/public_html') 213 | >>> await client.change_directory("folder1") 214 | >>> await client.get_current_directory() 215 | PosixPath('/public_html/folder1') 216 | >>> await client.change_directory() 217 | >>> await client.get_current_directory() 218 | PosixPath('/public_html') 219 | >>> await client.make_directory("folder2") 220 | >>> await client.change_directory("folder2") 221 | >>> await client.get_current_directory() 222 | PosixPath('/public_html/folder2') 223 | 224 | Rename (move) path 225 | ------------------ 226 | 227 | To change name (move) file or directory use :py:meth:`aioftp.Client.rename`. 228 | 229 | :: 230 | 231 | >>> await client.list() 232 | [(PosixPath('test.py'), {'modify': '20150423090041', 'type': 'file', ... 233 | >>> await client.rename("test.py", "foo.py") 234 | >>> await client.list() 235 | [(PosixPath('foo.py'), {'modify': '20150423090041', 'type': 'file', ... 236 | 237 | Closing connection 238 | ------------------ 239 | 240 | :py:meth:`aioftp.Client.quit` coroutine will send "QUIT" ftp command and close 241 | connection. 242 | 243 | :: 244 | 245 | >>> await client.quit() 246 | 247 | Advanced download and upload, abort, restart 248 | -------------------------------------------- 249 | 250 | File read/write operations are blocking and slow. So if you want just 251 | parse/calculate something on the fly when receiving file, or generate data 252 | to upload it to file system on ftp server, then you should use 253 | :py:meth:`aioftp.Client.download_stream`, 254 | :py:meth:`aioftp.Client.upload_stream` and 255 | :py:meth:`aioftp.Client.append_stream`. All this methods based on 256 | :py:meth:`aioftp.Client.get_stream`, which return 257 | :py:class:`aioftp.DataConnectionThrottleStreamIO`. The common pattern to 258 | work with streams is: 259 | 260 | :: 261 | 262 | >>> async with client.download_stream("tmp.py") as stream: 263 | ... async for block in stream.iter_by_block(): 264 | ... # do something with data 265 | 266 | Or, if you want to abort transfer at some point 267 | 268 | :: 269 | 270 | >>> stream = await client.download_stream("tmp.py") 271 | ... async for block in stream.iter_by_block(): 272 | ... # do something with data 273 | ... if something_not_interesting: 274 | ... await client.abort() 275 | ... stream.close() 276 | ... break 277 | ... else: 278 | ... await stream.finish() 279 | 280 | WARNING 281 | ^^^^^^^ 282 | 283 | Do not use `async with ` syntax if you want to use `abort`, this will 284 | lead to deadlock. 285 | 286 | For restarting upload/download at exact byte position (REST command) there is 287 | `offset` argument for `*_stream` methods: 288 | 289 | :: 290 | 291 | >>> async with client.download_stream("tmp.py", offset=256) as stream: 292 | ... async for block in stream.iter_by_block(): 293 | ... # do something with data 294 | 295 | Or if you want to restore upload/download process: 296 | 297 | :: 298 | 299 | >>> while True: 300 | ... try: 301 | ... async with aioftp.Client.context(HOST, PORT) as client: 302 | ... if await client.exists(filename): 303 | ... stat = await client.stat(filename) 304 | ... size = int(stat["size"]) 305 | ... else: 306 | ... size = 0 307 | ... file_in.seek(size) 308 | ... async with client.upload_stream(filename, offset=size) as stream: 309 | ... while True: 310 | ... data = file_in.read(block_size) 311 | ... if not data: 312 | ... break 313 | ... await stream.write(data) 314 | ... break 315 | ... except ConnectionResetError: 316 | ... pass 317 | 318 | The idea is to seek position of source «file» for upload and start 319 | upload + offset/append. Opposite situation for download («file» append and 320 | download + offset) 321 | 322 | Throttle 323 | -------- 324 | 325 | Client have two types of speed limit: `read_speed_limit` and 326 | `write_speed_limit`. Throttle can be set at initialization time: 327 | 328 | :: 329 | 330 | >>> client = aioftp.Client(read_speed_limit=100 * 1024) # 100 Kib/s 331 | 332 | And can be changed after creation: 333 | 334 | :: 335 | 336 | >>> client.throttle.write.limit = 250 * 1024 337 | 338 | Path abstraction layer 339 | ---------------------- 340 | 341 | aioftp provides abstraction of file system operations. You can use exist ones: 342 | 343 | * :py:class:`aioftp.PathIO` — blocking path operations 344 | * :py:class:`aioftp.AsyncPathIO` — non-blocking path operations, this one is 345 | blocking ones just wrapped with 346 | :py:meth:`asyncio.BaseEventLoop.run_in_executor`. It's really slow, so it's 347 | better to avoid usage of this path io layer. 348 | * :py:class:`aioftp.MemoryPathIO` — in-memory realization of file system, this 349 | one is just proof of concept and probably not too fast (as it can be). 350 | 351 | You can specify `path_io_factory` when creating :py:class:`aioftp.Client` 352 | instance. Default factory is :py:class:`aioftp.PathIO`. 353 | 354 | :: 355 | 356 | >>> client = aioftp.Client(path_io_factory=pathio.MemoryPathIO) 357 | 358 | Timeouts 359 | -------- 360 | 361 | :py:class:`aioftp.Client` have `socket_timeout` argument, which you can use 362 | to specify global timeout for socket io operations. 363 | 364 | :: 365 | 366 | >>> client = aioftp.Client(socket_timeout=1) # 1 second socket timeout 367 | 368 | :py:class:`aioftp.Client` also have `path_timeout`, which is applied 369 | **only for non-blocking path io layers**. 370 | 371 | :: 372 | 373 | >>> client = aioftp.Client( 374 | ... path_timeout=1, 375 | ... path_io_factory=pathio.AsyncPathIO 376 | ... ) 377 | 378 | TLS Upgrade Support 379 | ------------------- 380 | 381 | Just like Python's `ftplib.FTP_TLS`, aioftp supports TLS upgrade. This is 382 | done by calling :py:meth:`aioftp.Client.upgrade_to_tls` after instantiating the 383 | client, like: 384 | 385 | :: 386 | 387 | >>> client = aioftp.Client() 388 | >>> await client.connect("ftp.server.com") 389 | >>> await client.upgrade_to_tls() 390 | >>> await client.login("user", "pass") 391 | 392 | Using proxy 393 | ----------- 394 | 395 | Simplest way to use socks proxy with :class:`aioftp.Client` 396 | is `siosocks `_ 397 | 398 | :: 399 | 400 | >>> client = aioftp.Client( 401 | ... socks_host="localhost", 402 | ... socks_port=9050, 403 | ... socks_version=5, 404 | ... ) 405 | 406 | Don't forget to install `aioftp` as `pip install aioftp[socks]`, or install 407 | `siosocks` directly with `pip install siosocks`. 408 | 409 | WARNING 410 | ------- 411 | 412 | :py:meth:`aioftp.Client.list` and :py:meth:`aioftp.Client.stat` in general 413 | use `MLSD` and `MLST`, but some nasty servers does not support this commands. 414 | Then client will try to use `LIST` command, and parse server response. 415 | For proper work of :py:meth:`datetime.datetime.strptime` (in part of parsing 416 | month abbreviation) locale should be setted to "C". For this reason if you use 417 | multithreaded app, and use some locale-dependent stuff, you should use 418 | :py:meth:`aioftp.setlocale` context manager when you dealing with locale in 419 | another thread. 420 | **since 0.8.1** 421 | If fallback `LIST` parser can't parse line, then this line will be ignored, so 422 | fallback `LIST` implementation will never raise exception. 423 | 424 | Futher reading 425 | -------------- 426 | :doc:`client_api` 427 | -------------------------------------------------------------------------------- /docs/common_api.rst: -------------------------------------------------------------------------------- 1 | .. common_api: 2 | 3 | Common API 4 | ========== 5 | 6 | .. autoclass:: aioftp.StreamIO 7 | :members: 8 | 9 | .. autoclass:: aioftp.Throttle 10 | :members: 11 | 12 | .. autoclass:: aioftp.StreamThrottle 13 | :members: 14 | 15 | .. autoclass:: aioftp.ThrottleStreamIO 16 | :members: 17 | :show-inheritance: 18 | 19 | .. autoclass:: aioftp.AsyncListerMixin 20 | 21 | .. autoclass:: aioftp.AbstractAsyncLister 22 | 23 | .. autofunction:: aioftp.with_timeout 24 | 25 | .. autofunction:: aioftp.async_enterable 26 | 27 | .. autofunction:: aioftp.setlocale 28 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # aioftp documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Apr 17 16:21:03 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import os 16 | import sys 17 | 18 | import alabaster 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | sys.path.insert(0, os.path.abspath("..")) 24 | 25 | import aioftp # noqa 26 | 27 | # -- General configuration ------------------------------------------------ 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.intersphinx", 38 | "alabaster", 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ["_templates"] 43 | 44 | # The suffix(es) of source filenames. 45 | # You can specify multiple suffix as a list of string: 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = ".rst" 48 | 49 | # The encoding of source files. 50 | # source_encoding = 'utf-8-sig' 51 | 52 | # The master toctree document. 53 | master_doc = "index" 54 | 55 | # General information about the project. 56 | project = "aioftp" 57 | copyright = "2016, pohmelie" 58 | author = "pohmelie" 59 | 60 | # The version info for the project you're documenting, acts as replacement for 61 | # |version| and |release|, also used in various other places throughout the 62 | # built documents. 63 | # 64 | # The short X.Y version. 65 | version = aioftp.__version__ 66 | # The full version, including alpha/beta/rc tags. 67 | release = aioftp.__version__ 68 | 69 | # The language for content autogenerated by Sphinx. Refer to documentation 70 | # for a list of supported languages. 71 | # 72 | # This is also used if you do content translation via gettext catalogs. 73 | # Usually you set "language" from the command line for these cases. 74 | language = None 75 | 76 | # There are two options for replacing |today|: either, you set today to some 77 | # non-false value, then it is used: 78 | # today = '' 79 | # Else, today_fmt is used as the format for a strftime call. 80 | # today_fmt = '%B %d, %Y' 81 | 82 | # List of patterns, relative to source directory, that match files and 83 | # directories to ignore when looking for source files. 84 | exclude_patterns = ["_build"] 85 | 86 | # The reST default role (used for this markup: `text`) to use for all 87 | # documents. 88 | # default_role = None 89 | 90 | # If true, '()' will be appended to :func: etc. cross-reference text. 91 | # add_function_parentheses = True 92 | 93 | # If true, the current module name will be prepended to all description 94 | # unit titles (such as .. function::). 95 | # add_module_names = True 96 | 97 | # If true, sectionauthor and moduleauthor directives will be shown in the 98 | # output. They are ignored by default. 99 | # show_authors = False 100 | 101 | # The name of the Pygments (syntax highlighting) style to use. 102 | pygments_style = "sphinx" 103 | 104 | # A list of ignored prefixes for module index sorting. 105 | # modindex_common_prefix = [] 106 | 107 | # If true, keep warnings as "system message" paragraphs in the built documents. 108 | # keep_warnings = False 109 | 110 | # If true, `todo` and `todoList` produce output, else they produce nothing. 111 | todo_include_todos = False 112 | 113 | 114 | # -- Options for HTML output ---------------------------------------------- 115 | 116 | # The theme to use for HTML and HTML Help pages. See the documentation for 117 | # a list of builtin themes. 118 | html_theme = "alabaster" 119 | 120 | # Theme options are theme-specific and customize the look and feel of a theme 121 | # further. For a list of options available for each theme, see the 122 | # documentation. 123 | html_theme_options = { 124 | "logo": "logo.png", 125 | "description": "ftp client/server for asyncio", 126 | "github_user": "pohmelie", 127 | "github_repo": "aioftp", 128 | "github_button": True, 129 | "github_banner": True, 130 | # 'travis_button': True, 131 | "pre_bg": "#FFF6E5", 132 | "note_bg": "#E5ECD1", 133 | "note_border": "#BFCF8C", 134 | "body_text": "#482C0A", 135 | "sidebar_text": "#49443E", 136 | "sidebar_header": "#4B4032", 137 | "page_width": "90%", 138 | } 139 | 140 | # Add any paths that contain custom themes here, relative to this directory. 141 | html_theme_path = [alabaster.get_path()] 142 | 143 | # The name for this set of Sphinx documents. If None, it defaults to 144 | # " v documentation". 145 | # html_title = None 146 | 147 | # A shorter title for the navigation bar. Default is the same as html_title. 148 | # html_short_title = None 149 | 150 | # The name of an image file (relative to this directory) to place at the top 151 | # of the sidebar. 152 | # html_logo = None 153 | 154 | # The name of an image file (within the static path) to use as favicon of the 155 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 156 | # pixels large. 157 | html_favicon = "_static/logo.ico" 158 | 159 | # Add any paths that contain custom static files (such as style sheets) here, 160 | # relative to this directory. They are copied after the builtin static files, 161 | # so a file named "default.css" will overwrite the builtin "default.css". 162 | html_static_path = ["_static"] 163 | 164 | # Add any extra paths that contain custom files (such as robots.txt or 165 | # .htaccess) here, relative to this directory. These files are copied 166 | # directly to the root of the documentation. 167 | # html_extra_path = [] 168 | 169 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 170 | # using the given strftime format. 171 | # html_last_updated_fmt = '%b %d, %Y' 172 | 173 | # If true, SmartyPants will be used to convert quotes and dashes to 174 | # typographically correct entities. 175 | # html_use_smartypants = True 176 | 177 | # Custom sidebar templates, maps document names to template names. 178 | # html_sidebars = {} 179 | 180 | # Additional templates that should be rendered to pages, maps page names to 181 | # template names. 182 | # html_additional_pages = {} 183 | 184 | # If false, no module index is generated. 185 | # html_domain_indices = True 186 | 187 | # If false, no index is generated. 188 | # html_use_index = True 189 | 190 | # If true, the index is split into individual pages for each letter. 191 | # html_split_index = False 192 | 193 | # If true, links to the reST sources are added to the pages. 194 | # html_show_sourcelink = True 195 | 196 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 197 | # html_show_sphinx = True 198 | 199 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 200 | # html_show_copyright = True 201 | 202 | # If true, an OpenSearch description file will be output, and all pages will 203 | # contain a tag referring to it. The value of this option must be the 204 | # base URL from which the finished HTML is served. 205 | # html_use_opensearch = '' 206 | 207 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 208 | # html_file_suffix = None 209 | 210 | # Language to be used for generating the HTML full-text search index. 211 | # Sphinx supports the following languages: 212 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 213 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr' 214 | # html_search_language = 'en' 215 | 216 | # A dictionary with options for the search language support, empty by default. 217 | # Now only 'ja' uses this config value 218 | # html_search_options = {'type': 'default'} 219 | 220 | # The name of a javascript file (relative to the configuration directory) that 221 | # implements a search results scorer. If empty, the default will be used. 222 | # html_search_scorer = 'scorer.js' 223 | 224 | # Output file base name for HTML help builder. 225 | htmlhelp_basename = "aioftpdoc" 226 | 227 | # -- Options for LaTeX output --------------------------------------------- 228 | 229 | latex_elements = { 230 | # The paper size ('letterpaper' or 'a4paper'). 231 | #'papersize': 'letterpaper', 232 | # The font size ('10pt', '11pt' or '12pt'). 233 | #'pointsize': '10pt', 234 | # Additional stuff for the LaTeX preamble. 235 | #'preamble': '', 236 | # Latex figure (float) alignment 237 | #'figure_align': 'htbp', 238 | } 239 | 240 | # Grouping the document tree into LaTeX files. List of tuples 241 | # (source start file, target name, title, 242 | # author, documentclass [howto, manual, or own class]). 243 | latex_documents = [ 244 | ( 245 | master_doc, 246 | "aioftp.tex", 247 | "aioftp Documentation", 248 | "pohmelie", 249 | "manual", 250 | ), 251 | ] 252 | 253 | # The name of an image file (relative to this directory) to place at the top of 254 | # the title page. 255 | # latex_logo = None 256 | 257 | # For "manual" documents, if this is true, then toplevel headings are parts, 258 | # not chapters. 259 | # latex_use_parts = False 260 | 261 | # If true, show page references after internal links. 262 | # latex_show_pagerefs = False 263 | 264 | # If true, show URL addresses after external links. 265 | # latex_show_urls = False 266 | 267 | # Documents to append as an appendix to all manuals. 268 | # latex_appendices = [] 269 | 270 | # If false, no module index is generated. 271 | # latex_domain_indices = True 272 | 273 | 274 | # -- Options for manual page output --------------------------------------- 275 | 276 | # One entry per manual page. List of tuples 277 | # (source start file, name, description, authors, manual section). 278 | man_pages = [ 279 | ( 280 | master_doc, 281 | "aioftp", 282 | "aioftp Documentation", 283 | [author], 284 | 1, 285 | ), 286 | ] 287 | 288 | # If true, show URL addresses after external links. 289 | # man_show_urls = False 290 | 291 | 292 | # -- Options for Texinfo output ------------------------------------------- 293 | 294 | # Grouping the document tree into Texinfo files. List of tuples 295 | # (source start file, target name, title, author, 296 | # dir menu entry, description, category) 297 | texinfo_documents = [ 298 | ( 299 | master_doc, 300 | "aioftp", 301 | "aioftp Documentation", 302 | author, 303 | "aioftp", 304 | "One line description of project.", 305 | "Miscellaneous", 306 | ), 307 | ] 308 | 309 | # Documents to append as an appendix to all manuals. 310 | # texinfo_appendices = [] 311 | 312 | # If false, no module index is generated. 313 | # texinfo_domain_indices = True 314 | 315 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 316 | # texinfo_show_urls = 'footnote' 317 | 318 | # If true, do not generate a @detailmenu in the "Top" node's menu. 319 | # texinfo_no_detailmenu = False 320 | 321 | intersphinx_mapping = { 322 | "python": ("https://docs.python.org/3", None), 323 | "aioftp": ("./_build/html", None), 324 | } 325 | 326 | html_logo = "_static/logo.png" 327 | -------------------------------------------------------------------------------- /docs/developer_tutorial.rst: -------------------------------------------------------------------------------- 1 | .. developer_tutorial: 2 | 3 | Developer tutorial 4 | ================== 5 | 6 | Both, client and server classes are inherit-minded when created. So, you need 7 | to inherit class and override and/or add methods to bring your functionality. 8 | 9 | Client 10 | ------ 11 | 12 | For simple commands, which requires no extra connection, realization of new 13 | method is pretty simple. You just need to use :py:meth:`aioftp.Client.command` 14 | (or even don't use it). For example, lets realize «NOOP» command, which do 15 | nothing: 16 | 17 | :: 18 | 19 | class MyClient(aioftp.Client): 20 | async def noop(self): 21 | await self.command("NOOP", "2xx") 22 | 23 | Lets take a look to a more complex example. Say, we want to collect some data 24 | via extra connection. For this one you need one of «extra connection» methods: 25 | :py:meth:`aioftp.Client.download_stream`, 26 | :py:meth:`aioftp.Client.upload_stream`, :py:meth:`aioftp.Client.append_stream` 27 | or (for more complex situations) :py:meth:`aioftp.Client.get_stream` 28 | Here we implements some «COLL x» command. I don't know why, but it 29 | retrieve some data via extra connection. And the size of data is equal to «x». 30 | 31 | :: 32 | 33 | class MyClient(aioftp.Client): 34 | 35 | async def collect(self, count): 36 | collected = [] 37 | async with self.get_stream("COLL " + str(count), "1xx") as stream: 38 | async for block in stream.iter_by_block(8): 39 | i = int.from_bytes(block, "big") 40 | print("received:", block, i) 41 | collected.append(i) 42 | return collected 43 | 44 | Client retrieve passive (or active in future versions) via `get_stream` and 45 | read blocks of data until connection is closed. Then finishing stream and 46 | return result. Most of client functions (except low-level from BaseClient) 47 | are made in pretty same manner. It is a good idea you to see source code of 48 | :py:class:`aioftp.Client` in client.py to see when and why this or that 49 | techniques used. 50 | 51 | Server 52 | ------ 53 | 54 | Server class based on dispatcher, which wait for result of tasks via 55 | :py:meth:`asyncio.wait`. Tasks are different: command-reader, result-writer, 56 | commander-action, extra-connection-workers. FTP methods dispatched by name. 57 | 58 | Lets say we want implement «NOOP» command for server again: 59 | 60 | :: 61 | 62 | class MyServer(aioftp.Server): 63 | 64 | async def noop(self, connection, rest): 65 | connection.response("200", "boring") 66 | return True 67 | 68 | What we have here? Dispatcher calls our method with some arguments: 69 | 70 | * `connection` is state of connection, this can hold and wait for futures. 71 | There many connection values you can interest in: addresses, throttles, 72 | timeouts, extra_workers, response, etc. You can add your own flags and values 73 | to the «connection» and edit the existing ones of course. It's better to see 74 | source code of server, cause connection is heart of dispatcher ↔ task and 75 | task ↔ task interaction and state container. 76 | * `rest`: rest part of command string 77 | 78 | There is some decorators, which can help for routine checks: is user logged, 79 | can he read/write this path, etc. 80 | :py:class:`aioftp.ConnectionConditions` 81 | :py:class:`aioftp.PathConditions` 82 | :py:class:`aioftp.PathPermissions` 83 | 84 | For more complex example lets try same client «COLL x» command. 85 | 86 | :: 87 | 88 | class MyServer(aioftp.Server): 89 | 90 | @aioftp.ConnectionConditions( 91 | aioftp.ConnectionConditions.login_required, 92 | aioftp.ConnectionConditions.passive_server_started) 93 | async def coll(self, connection, rest): 94 | 95 | @aioftp.ConnectionConditions( 96 | aioftp.ConnectionConditions.data_connection_made, 97 | wait=True, 98 | fail_code="425", 99 | fail_info="Can't open data connection") 100 | @aioftp.server.worker 101 | async def coll_worker(self, connection, rest): 102 | stream = connection.data_connection 103 | del connection.data_connection 104 | async with stream: 105 | for i in range(count): 106 | binary = i.to_bytes(8, "big") 107 | await stream.write(binary) 108 | connection.response("200", "coll transfer done") 109 | return True 110 | 111 | count = int(rest) 112 | coro = coll_worker(self, connection, rest) 113 | task = connection.loop.create_task(coro) 114 | connection.extra_workers.add(task) 115 | connection.response("150", "coll transfer started") 116 | return True 117 | 118 | This action requires passive connection, that is why we use worker. We 119 | should be able to receive commands when receiving data with extra connection, 120 | that is why we send our task to dispatcher via `extra_workers`. Task will be 121 | pending on next «iteration» of dispatcher. 122 | 123 | Lets see what we have. 124 | 125 | :: 126 | 127 | async def test(): 128 | server = MyServer() 129 | client = MyClient() 130 | await server.start("127.0.0.1", 8021) 131 | await client.connect("127.0.0.1", 8021) 132 | await client.login() 133 | collected = await client.collect(20) 134 | print(collected) 135 | await client.quit() 136 | await server.close() 137 | 138 | 139 | if __name__ == "__main__": 140 | logging.basicConfig( 141 | level=logging.INFO, 142 | format="%(asctime)s [%(name)s] %(message)s", 143 | datefmt="[%H:%M:%S]:", 144 | ) 145 | loop = asyncio.get_event_loop() 146 | loop.run_until_complete(test()) 147 | print("done") 148 | 149 | 150 | And the output for this is: 151 | 152 | :: 153 | 154 | [01:18:54]: [aioftp.server] serving on 127.0.0.1:8021 155 | [01:18:54]: [aioftp.server] new connection from 127.0.0.1:48883 156 | [01:18:54]: [aioftp.server] 220 welcome 157 | [01:18:54]: [aioftp.client] 220 welcome 158 | [01:18:54]: [aioftp.client] USER anonymous 159 | [01:18:54]: [aioftp.server] USER anonymous 160 | [01:18:54]: [aioftp.server] 230 anonymous login 161 | [01:18:54]: [aioftp.client] 230 anonymous login 162 | [01:18:54]: [aioftp.client] TYPE I 163 | [01:18:54]: [aioftp.server] TYPE I 164 | [01:18:54]: [aioftp.server] 200 165 | [01:18:54]: [aioftp.client] 200 166 | [01:18:54]: [aioftp.client] PASV 167 | [01:18:54]: [aioftp.server] PASV 168 | [01:18:54]: [aioftp.server] 227-listen socket created 169 | [01:18:54]: [aioftp.server] 227 (127,0,0,1,223,249) 170 | [01:18:54]: [aioftp.client] 227-listen socket created 171 | [01:18:54]: [aioftp.client] 227 (127,0,0,1,223,249) 172 | [01:18:54]: [aioftp.client] COLL 20 173 | [01:18:54]: [aioftp.server] COLL 20 174 | [01:18:54]: [aioftp.server] 150 coll transfer started 175 | [01:18:54]: [aioftp.client] 150 coll transfer started 176 | received: b'\x00\x00\x00\x00\x00\x00\x00\x00' 0 177 | received: b'\x00\x00\x00\x00\x00\x00\x00\x01' 1 178 | received: b'\x00\x00\x00\x00\x00\x00\x00\x02' 2 179 | received: b'\x00\x00\x00\x00\x00\x00\x00\x03' 3 180 | received: b'\x00\x00\x00\x00\x00\x00\x00\x04' 4 181 | received: b'\x00\x00\x00\x00\x00\x00\x00\x05' 5 182 | received: b'\x00\x00\x00\x00\x00\x00\x00\x06' 6 183 | received: b'\x00\x00\x00\x00\x00\x00\x00\x07' 7 184 | received: b'\x00\x00\x00\x00\x00\x00\x00\x08' 8 185 | received: b'\x00\x00\x00\x00\x00\x00\x00\t' 9 186 | received: b'\x00\x00\x00\x00\x00\x00\x00\n' 10 187 | received: b'\x00\x00\x00\x00\x00\x00\x00\x0b' 11 188 | received: b'\x00\x00\x00\x00\x00\x00\x00\x0c' 12 189 | received: b'\x00\x00\x00\x00\x00\x00\x00\r' 13 190 | received: b'\x00\x00\x00\x00\x00\x00\x00\x0e' 14 191 | received: b'\x00\x00\x00\x00\x00\x00\x00\x0f' 15 192 | received: b'\x00\x00\x00\x00\x00\x00\x00\x10' 16 193 | received: b'\x00\x00\x00\x00\x00\x00\x00\x11' 17 194 | received: b'\x00\x00\x00\x00\x00\x00\x00\x12' 18 195 | [01:18:54]: [aioftp.server] 200 coll transfer done 196 | received: b'\x00\x00\x00\x00\x00\x00\x00\x13' 19 197 | [01:18:54]: [aioftp.client] 200 coll transfer done 198 | [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] 199 | [01:18:54]: [aioftp.client] QUIT 200 | [01:18:54]: [aioftp.server] QUIT 201 | [01:18:54]: [aioftp.server] 221 bye 202 | [01:18:54]: [aioftp.server] closing connection from 127.0.0.1:48883 203 | [01:18:54]: [aioftp.client] 221 bye 204 | done 205 | 206 | It is a good idea you to see source code of :py:class:`aioftp.Server` in 207 | server.py to see when and why this or that techniques used. 208 | 209 | Path abstraction layer 210 | ---------------------- 211 | 212 | Since file io is blocking and aioftp tries to be non-blocking ftp library, we 213 | need some abstraction layer for filesystem operations. That is why pathio 214 | exists. If you want to create your own pathio, then you should inherit 215 | :py:class:`aioftp.AbstractPathIO` and override it methods. 216 | 217 | User Manager 218 | ------------ 219 | 220 | User manager purpose is to split retrieving user information from network or 221 | database and server logic. You can create your own user manager by inherit 222 | :py:class:`aioftp.AbstractUserManager` and override it methods. The new user 223 | manager should be passed to server as `users` argument when initialize server. 224 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. aioftp documentation master file, created by 2 | sphinx-quickstart on Fri Apr 17 16:21:03 2015. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | aioftp 7 | ====== 8 | 9 | ftp client/server for asyncio. 10 | 11 | .. _GitHub: https://github.com/aio-libs/aioftp 12 | 13 | Features 14 | -------- 15 | 16 | - Simple. 17 | - Extensible. 18 | - Client socks proxy via `siosocks `_ 19 | (`pip install aioftp[socks]`). 20 | 21 | Goals 22 | ----- 23 | 24 | - Minimum usable core. 25 | - Do not use deprecated or overridden commands and features (if possible). 26 | - Very high level api. 27 | 28 | Client use this commands: USER, PASS, ACCT, PWD, CWD, CDUP, MKD, RMD, MLSD, 29 | MLST, RNFR, RNTO, DELE, STOR, APPE, RETR, TYPE, PASV, ABOR, QUIT, REST, LIST 30 | (as fallback) 31 | 32 | Server support this commands: USER, PASS, QUIT, PWD, CWD, CDUP, MKD, RMD, MLSD, 33 | LIST (but it's not recommended to use it, cause it has no standard format), 34 | MLST, RNFR, RNTO, DELE, STOR, RETR, TYPE ("I" and "A"), PASV, ABOR, APPE, REST 35 | 36 | This subsets are enough for 99% of tasks, but if you need something, then you 37 | can easily extend current set of commands. 38 | 39 | Server benchmark 40 | ---------------- 41 | 42 | Compared with `pyftpdlib `_ and 43 | checked with its ftpbench script. 44 | 45 | aioftp 0.8.0 46 | 47 | :: 48 | 49 | STOR (client -> server) 284.95 MB/sec 50 | RETR (server -> client) 408.44 MB/sec 51 | 200 concurrent clients (connect, login) 0.18 secs 52 | STOR (1 file with 200 idle clients) 287.52 MB/sec 53 | RETR (1 file with 200 idle clients) 382.05 MB/sec 54 | 200 concurrent clients (RETR 10.0M file) 13.33 secs 55 | 200 concurrent clients (STOR 10.0M file) 12.56 secs 56 | 200 concurrent clients (QUIT) 0.03 secs 57 | 58 | pyftpdlib 1.5.2 59 | 60 | :: 61 | 62 | STOR (client -> server) 1235.56 MB/sec 63 | RETR (server -> client) 3960.21 MB/sec 64 | 200 concurrent clients (connect, login) 0.06 secs 65 | STOR (1 file with 200 idle clients) 1208.58 MB/sec 66 | RETR (1 file with 200 idle clients) 3496.03 MB/sec 67 | 200 concurrent clients (RETR 10.0M file) 0.55 secs 68 | 200 concurrent clients (STOR 10.0M file) 1.46 secs 69 | 200 concurrent clients (QUIT) 0.02 secs 70 | 71 | Dependencies 72 | ------------ 73 | 74 | - Python 3.9+ 75 | 76 | 0.13.0 is the last version which supports python 3.5.3+ 77 | 78 | 0.16.1 is the last version which supports python 3.6+ 79 | 80 | 0.21.4 is the last version which supports python 3.7+ 81 | 82 | 0.22.3 is the last version which supports python 3.8+ 83 | 84 | License 85 | ------- 86 | 87 | aioftp is offered under the Apache 2 license. 88 | 89 | Library installation 90 | -------------------- 91 | 92 | :: 93 | 94 | pip install aioftp 95 | 96 | Getting started 97 | --------------- 98 | 99 | Client example 100 | 101 | **WARNING** 102 | 103 | For all commands, which use some sort of «stats» or «listing», ``aioftp`` tries 104 | at first ``MLSx``-family commands (since they have structured, machine readable 105 | format for all platforms). But old/lazy/nasty servers do not implement this 106 | commands. In this case ``aioftp`` tries a ``LIST`` command, which have no 107 | standard format and can not be parsed in all cases. Take a look at 108 | `FileZilla `_ 109 | «directory listing» parser code. So, before creating new issue be sure this 110 | is not your case (you can check it with logs). Anyway, you can provide your own 111 | ``LIST`` parser routine (see the client documentation). 112 | 113 | .. code-block:: python 114 | 115 | import asyncio 116 | import aioftp 117 | 118 | 119 | async def get_mp3(host, port, login, password): 120 | async with aioftp.Client.context(host, port, login, password) as client: 121 | for path, info in (await client.list(recursive=True)): 122 | if info["type"] == "file" and path.suffix == ".mp3": 123 | await client.download(path) 124 | 125 | 126 | async def main(): 127 | tasks = [ 128 | asyncio.create_task(get_mp3("server1.com", 21, "login", "password")), 129 | asyncio.create_task(get_mp3("server2.com", 21, "login", "password")), 130 | asyncio.create_task(get_mp3("server3.com", 21, "login", "password")), 131 | ] 132 | await asyncio.wait(tasks) 133 | 134 | asyncio.run(main()) 135 | 136 | Server example 137 | 138 | .. code-block:: python 139 | 140 | import asyncio 141 | import aioftp 142 | 143 | 144 | async def main(): 145 | server = aioftp.Server([user], path_io_factory=path_io_factory) 146 | await server.run() 147 | 148 | asyncio.run(main()) 149 | 150 | Or just use simple server 151 | 152 | .. code-block:: shell 153 | 154 | python -m aioftp --help 155 | 156 | Further reading 157 | --------------- 158 | 159 | .. toctree:: 160 | :maxdepth: 2 161 | 162 | client_tutorial 163 | server_tutorial 164 | developer_tutorial 165 | client_api 166 | server_api 167 | common_api 168 | path_io_api 169 | 170 | Indices and tables 171 | ------------------ 172 | 173 | * :ref:`genindex` 174 | * :ref:`search` 175 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\aioftp.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\aioftp.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /docs/path_io_api.rst: -------------------------------------------------------------------------------- 1 | .. path_io_api: 2 | 3 | Path abstraction layer API 4 | ========================== 5 | 6 | .. autoclass :: aioftp.AbstractPathIO 7 | :members: 8 | :private-members: 9 | 10 | .. autoclass :: aioftp.pathio.AsyncPathIOContext 11 | 12 | .. autofunction :: aioftp.pathio.universal_exception 13 | 14 | .. autoclass :: aioftp.PathIO 15 | :show-inheritance: 16 | 17 | .. autoclass :: aioftp.AsyncPathIO 18 | :show-inheritance: 19 | 20 | .. autoclass :: aioftp.MemoryPathIO 21 | :show-inheritance: 22 | 23 | .. autoclass :: aioftp.PathIOError 24 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==5.3.0 2 | alabaster==0.7.13 3 | docutils==0.17.1 4 | -------------------------------------------------------------------------------- /docs/server_api.rst: -------------------------------------------------------------------------------- 1 | .. server_api: 2 | 3 | Server API 4 | ========== 5 | 6 | .. autoclass :: aioftp.Server 7 | :members: start, close, serve_forever, run 8 | :show-inheritance: 9 | 10 | .. autofunction :: aioftp.server.worker 11 | 12 | .. autoclass :: aioftp.User 13 | :members: 14 | 15 | .. autoclass :: aioftp.Permission 16 | :members: 17 | 18 | .. autoclass :: aioftp.AbstractUserManager 19 | :members: 20 | :exclude-members: GetUserResponse 21 | 22 | .. autoclass :: aioftp.server.MemoryUserManager 23 | :members: 24 | 25 | .. autoclass :: aioftp.Connection 26 | :members: 27 | :show-inheritance: 28 | 29 | .. autoclass :: aioftp.AvailableConnections 30 | :members: 31 | 32 | .. autoclass :: aioftp.ConnectionConditions 33 | :members: 34 | 35 | .. autoclass :: aioftp.PathConditions 36 | :members: 37 | 38 | .. autoclass :: aioftp.PathPermissions 39 | :members: 40 | -------------------------------------------------------------------------------- /docs/server_tutorial.rst: -------------------------------------------------------------------------------- 1 | .. server_tutorial: 2 | 3 | Server tutorial 4 | =============== 5 | 6 | aioftp server is much more like a tool. You configure it, run and forget about 7 | it. 8 | 9 | Configuring server 10 | ------------------ 11 | 12 | At first you should create :class:`aioftp.Server` instance and start it 13 | 14 | :py:meth:`aioftp.Server.start` 15 | 16 | :: 17 | 18 | >>> server = aioftp.Server() 19 | >>> await server.start() 20 | 21 | Default arguments allow anonymous login and read/write current directory. So, 22 | there is one user with anonymous login and read/write permissions on "/" 23 | virtual path. Real path is current working directory. 24 | 25 | Dealing with users and permissions 26 | ---------------------------------- 27 | 28 | You can specify as much users as you want, just pass list of them when creating 29 | :class:`aioftp.Server` instance 30 | 31 | :class:`aioftp.User` 32 | 33 | :class:`aioftp.Permission` 34 | 35 | :: 36 | 37 | >>> users = ( 38 | ... aioftp.User( 39 | ... "Guido", 40 | ... "secret_password", 41 | ... home_path="/Guido", 42 | ... permissions=( 43 | ... aioftp.Permission("/", readable=False, writable=False), 44 | ... aioftp.Permission("/Guido", readable=True, writable=True), 45 | ... ) 46 | ... ), 47 | ... aioftp.User( 48 | ... home_path="/anon", 49 | ... permissions=( 50 | ... aioftp.Permission("/", readable=False, writable=False), 51 | ... aioftp.Permission("/anon", readable=True), 52 | ... ) 53 | ... ), 54 | ... ) 55 | >>> server = aioftp.Server(users) 56 | >>> await server.start() 57 | 58 | This will create two users: "Guido", who can read and write to "/Guido" folder, 59 | which is home folder, but can't read/write the root and other directories and 60 | anonymous user, who home directory is "/anon" and there is only read 61 | permission. 62 | 63 | Path abstraction layer 64 | ---------------------- 65 | 66 | aioftp provides abstraction of file system operations. You can use exist ones: 67 | 68 | * :py:class:`aioftp.PathIO` — blocking path operations 69 | * :py:class:`aioftp.AsyncPathIO` — non-blocking path operations, this one is 70 | blocking ones just wrapped with 71 | :py:meth:`asyncio.BaseEventLoop.run_in_executor`. It's really slow, so it's 72 | better to avoid usage of this path io layer. 73 | * :py:class:`aioftp.MemoryPathIO` — in-memory realization of file system, this 74 | one is just proof of concept and probably not too fast (as it can be). 75 | 76 | You can specify `path_io_factory` when creating :py:class:`aioftp.Server` 77 | instance. Default factory is :py:class:`aioftp.PathIO`. 78 | 79 | :: 80 | 81 | >>> server = aioftp.Server(path_io_factory=aioftp.MemoryPathIO) 82 | >>> await server.start() 83 | 84 | Dealing with timeouts 85 | --------------------- 86 | 87 | There is three different timeouts you can specify: 88 | 89 | * `socket_timeout` — timeout for low-level socket operations 90 | :py:meth:`asyncio.StreamReader.read`, 91 | :py:meth:`asyncio.StreamReader.readline` and 92 | :py:meth:`asyncio.StreamWriter.drain`. This one does not affects awaiting 93 | command read operation. 94 | * `path_timeout` — timeout for file system operations 95 | * `idle_timeout` — timeout for socket read operation when awaiting command, 96 | another words: how long user can keep silence without sending commands 97 | * `wait_future_timeout` — timeout for waiting connection states (the main 98 | purpose is wait for passive connection) 99 | 100 | Maximum connections 101 | ------------------- 102 | 103 | Connections count can be specified: 104 | 105 | * per server 106 | * per user 107 | 108 | First one via server constructor 109 | 110 | :: 111 | 112 | >>> server = aioftp.Server(maximum_connections=3) 113 | 114 | Second one via user class 115 | 116 | :: 117 | 118 | >>> users = (aioftp.User(maximum_connections=3),) 119 | >>> server = aioftp.Server(users) 120 | 121 | Throttle 122 | -------- 123 | 124 | Server have many options for read/write speed throttle: 125 | 126 | * global per server 127 | * per connection 128 | * global per user 129 | * per user connection 130 | 131 | "Global per server" and "per connection" can be provided by constructor 132 | 133 | :: 134 | 135 | >>> server = aioftp.Server( 136 | ... read_speed_limit=1024 * 1024, 137 | ... write_speed_limit=1024 * 1024, 138 | ... read_speed_limit_per_connection=100 * 1024, 139 | ... write_speed_limit_per_connection=100 * 1024 140 | ... ) 141 | 142 | User throttles can be provided by user constructor 143 | 144 | :: 145 | 146 | >>> users = ( 147 | ... aioftp.User( 148 | ... read_speed_limit=1024 * 1024, 149 | ... write_speed_limit=1024 * 1024, 150 | ... read_speed_limit_per_connection=100 * 1024, 151 | ... write_speed_limit_per_connection=100 * 1024 152 | ... ), 153 | ... ) 154 | >>> server = aioftp.Server(users) 155 | 156 | Stopping the server 157 | ------------------- 158 | 159 | :: 160 | 161 | >>> await server.close() 162 | 163 | WARNING 164 | ------- 165 | 166 | :py:meth:`aioftp.Server.list` use :py:meth:`aioftp.Server.build_list_string`, 167 | which should produce `LIST` strings with :py:meth:`datetime.datetime.strftime`. 168 | For proper work (in part of formatting month abbreviation) locale should be 169 | setted to "C". For this reason if you use multithreaded app, and use some 170 | locale-dependent stuff, you should use :py:meth:`aioftp.setlocale` context 171 | manager when you dealing with locale in another thread. 172 | 173 | Futher reading 174 | -------------- 175 | :doc:`server_api` 176 | -------------------------------------------------------------------------------- /history.rst: -------------------------------------------------------------------------------- 1 | x.x.x (xxxx-xx-xx) 2 | 3 | 0.25.0 (2025-04-08) 4 | ------------------- 5 | - client: add partial client support for explicit tls (#183) 6 | Thanks to `bellini666 `_ 7 | 8 | 0.24.1 (2024-12-13) 9 | ------------------- 10 | - server: use single line pasv response (fix #142) 11 | 12 | 0.24.0 (2024-12-11) 13 | ------------------- 14 | - remove documentation dependencies from pyproject.toml (moved to docs/requirements.txt) 15 | - include symlink destination in path info for unix legacy mode (#169) 16 | - update documentation links (#180) 17 | Thanks to `webknjaz `_, `rcfox `_ 18 | 19 | 0.23.1 (2024-10-14) 20 | ------------------- 21 | - update ci 22 | 23 | 0.23.0 (2024-10-14) 24 | ------------------- 25 | - server: fix pathlib `relative_to` issue (#179) 26 | - minimal python version upgraded to 3.9 27 | 28 | 0.22.3 (2024-01-05) 29 | ------------------- 30 | - minimal python version downgraded to 3.8 31 | 32 | 0.22.2 (2023-12-29) 33 | ------------------- 34 | - ci: separate build and publish jobs 35 | 36 | 0.22.1 (2023-12-29) 37 | ------------------- 38 | - docs: update/fix readthedocs configuration 39 | - ci: fix workflow file extension from `yaml` to `yml` 40 | 41 | 0.22.0 (2023-12-29) 42 | ------------------- 43 | - client.list: fix infinite symlink loop for `.` and `..` on FTP servers with UNIX-like filesystem for `client.list(path, recursive=True)` 44 | - project file structure: refactor to use `pyproject.toml` 45 | - minimal python version bumped to 3.11 46 | - ci: update publish/deploy job (#171) 47 | 48 | 0.21.4 (2022-10-13) 49 | ------------------- 50 | - tests: use `pytest_asyncio` `strict` mode and proper decorations (#155) 51 | - setup/tests: set low bound for version of `async-timeout` (#159) 52 | 53 | 0.21.3 (2022-07-15) 54 | ------------------- 55 | - server/`LIST`: prevent broken links are listed, but can't be used with `stat` 56 | - server: make `User.get_permissions` async 57 | 58 | 0.21.2 (2022-04-22) 59 | ------------------- 60 | - tests: remove exception representation check 61 | 62 | 0.21.1 (2022-04-20) 63 | ------------------- 64 | - tests: replace more specific `ConnectionRefusedError` with `OSError` for compatibility with FreeBSD (#152) 65 | Thanks to `AMDmi3 https://github.com/AMDmi3`_ 66 | 67 | 0.21.0 (2022-03-18) 68 | ------------------- 69 | - server: support PASV response with custom address (#150) 70 | Thanks to `janneronkko https://github.com/janneronkko`_ 71 | 72 | 0.20.1 (2022-02-15) 73 | ------------------- 74 | - server: fix real directory resolve for windows (#147) 75 | Thanks to `ported-pw https://github.com/ported-pw`_ 76 | 77 | 0.20.0 (2021-12-27) 78 | ------------------- 79 | - add client argument to set priority of custom list parser (`parse_list_line_custom_first`) (#145) 80 | - do not ignore failed parsing of list response (#144) 81 | Thanks to `spolloni https://github.com/spolloni`_ 82 | 83 | 0.19.0 (2021-10-08) 84 | ------------------- 85 | - add client connection timeout (#140) 86 | - remove explicit coroutine passing to `asyncio.wait` (#134) 87 | Thanks to `decaz `_ 88 | 89 | 0.18.1 (2020-10-03) 90 | ------------------- 91 | - sync tests with new `siosocks` (#127) 92 | - some docs fixes 93 | - log level changes 94 | 95 | 0.18.0 (2020-09-03) 96 | ------------------- 97 | - server: fix `MLSX` time format (#125) 98 | - server: resolve server address from connection (#125) 99 | Thanks to `PonyPC `_ 100 | 101 | 0.17.2 (2020-08-21) 102 | ------------------- 103 | - server: fix broken `python -m aioftp` after 3.7 migration 104 | 105 | 0.17.1 (2020-08-14) 106 | ------------------- 107 | - common/stream: add `readexactly` proxy method 108 | 109 | 0.17.0 (2020-08-11) 110 | ------------------- 111 | - tests: fix test_unlink_on_dir on POSIX compatible systems (#118) 112 | - docs: fix extra parentheses (#122) 113 | - client: replace `ClientSession` with `Client.context` 114 | Thanks to `AMDmi3 `_, `Olegt0rr `_ 115 | 116 | 0.16.1 (2020-07-09) 117 | ------------------- 118 | - client: strip date before parsing (#113) 119 | - client: logger no longer prints out plaintext password (#114) 120 | - client: add custom passive commands to client (#116) 121 | Thanks to `ndhansen `_ 122 | 123 | 0.16.0 (2020-03-11) 124 | ------------------- 125 | - server: remove obsolete `pass` to `pass_` command renaming 126 | Thanks to `Puddly `_ 127 | 128 | - client: fix leap year bug at `parse_ls_date` method 129 | - all: add base exception class 130 | Thanks to `decaz `_ 131 | 132 | 0.15.0 (2020-01-07) 133 | ------------------- 134 | - server: use explicit mapping of available commands for security reasons 135 | Thanks to `Puddly` for report 136 | 137 | 0.14.0 (2019-12-30) 138 | ------------------- 139 | - client: add socks proxy support via `siosocks `_ (#94) 140 | - client: add custom `list` parser (#95) 141 | Thanks to `purpleskyfall `_, `VyachAp `_ 142 | 143 | 0.13.0 (2019-03-24) 144 | ------------------- 145 | - client: add windows list parser (#82) 146 | - client/server: fix implicit ssl mode (#89) 147 | - tests: move to pytest 148 | - all: small fixes 149 | Thanks to `jw4js `_, `PonyPC `_ 150 | 151 | 0.12.0 (2018-10-15) 152 | ------------------- 153 | - all: add implicit ftps mode support (#81) 154 | Thanks to `alxpy `_, `webknjaz `_ 155 | 156 | 0.11.1 (2018-08-30) 157 | ------------------- 158 | - server: fix memory pathio is not shared between connections 159 | - client: add argument to `list` to allow manually specifying raw command (#78) 160 | Thanks to `thirtyseven `_ 161 | 162 | 0.11.0 (2018-07-04) 163 | ------------------- 164 | - client: fix parsing `ls` modify time (#60) 165 | - all: add python3.7 support (`__aiter__` must be regular function since now) (#76, #77) 166 | Thanks to `saulcruz `_, `NickG123 `_, `rsichny `_, `Modelmat `_, `webknjaz `_ 167 | 168 | 0.10.1 (2018-03-01) 169 | ------------------- 170 | - client: more flexible `EPSV` response parsing 171 | Thanks to `p4l1ly `_ 172 | 173 | 0.10.0 (2018-02-03) 174 | ------------------- 175 | - server: fix ipv6 peername unpack 176 | - server: `connection` object is accessible from path-io layer since now 177 | - main: add command line argument to set version of IP protocol 178 | - setup: fix failed test session return zero exit code 179 | - client: fix `download`-`mkdir` (issue #68) 180 | - client/server: add initial ipv6 support (issue #63) 181 | - client: change `PASV` to `EPSV` with fallback to `PASV` 182 | Thanks to `jacobtomlinson `_, `mbkr1992 `_ 183 | 184 | 0.9.0 (2018-01-04) 185 | ------------------ 186 | - server: fix server address in passive mode 187 | - server: do not reraise dispatcher exceptions 188 | - server: remove `wait_closed`, `close` is coroutine since now 189 | Thanks to `yieyu `_, `jkr78 `_ 190 | 191 | 0.8.1 (2017-10-08) 192 | ------------------ 193 | - client: ignore LIST lines, which can't be parsed 194 | Thanks to `bachya `_ 195 | 196 | 0.8.0 (2017-08-06) 197 | ------------------ 198 | - client/server: add explicit encoding 199 | Thanks to `anan-lee `_ 200 | 201 | 0.7.0 (2017-04-17) 202 | ------------------ 203 | - client: add base `LIST` parsing 204 | - client: add `client.list` fallback on `MLSD` «not implemented» status code to `LIST` 205 | - client: add `client.stat` fallback on `MLST` «not implemented» status code to `LIST` 206 | - common: add `setlocale` context manager for `LIST` parsing, formatting and thread-safe usage of locale 207 | - server: add `LIST` support for non-english locales 208 | - server: fix `PASV` sequencies before data transfer (latest `PASV` win) 209 | Thanks to `jw4js `_, `rsichny `_ 210 | 211 | 0.6.3 (2017-03-02) 212 | ------------------ 213 | - `stream.read` will read whole data by default (as `asyncio.StreamReader.read`) 214 | Thanks to `sametmax `_ 215 | 216 | 0.6.2 (2017-02-27) 217 | ------------------ 218 | - replace `docopt` with `argparse` 219 | - add `syst` server command 220 | - improve client `list` documentation 221 | Thanks to `thelostt `_, `yieyu `_ 222 | 223 | 0.6.1 (2016-04-16) 224 | ------------------ 225 | - fix documentation main page client example 226 | 227 | 0.6.0 (2016-04-16) 228 | ------------------ 229 | - fix `modifed time` field for `list` command result 230 | - add `ClientSession` context 231 | - add `REST` command to server and client 232 | Thanks to `rsichny `_ 233 | 234 | 0.5.0 (2016-02-12) 235 | ------------------ 236 | - change development status to production/stable 237 | - add configuration to restrict port range for passive server 238 | - build LIST string with stat.filemode 239 | Thanks to `rsichny `_ 240 | 241 | 0.4.1 (2015-12-21) 242 | ------------------ 243 | - improved performance on non-throttled streams 244 | - default path io layer for client and server is PathIO since now 245 | - added benchmark result 246 | 247 | 0.4.0 (2015-12-17) 248 | ------------------ 249 | - `async for` for pathio list function 250 | - async context manager for streams and pathio files io 251 | - python 3.5 only 252 | - logging provided by "aioftp.client" and "aioftp.server" 253 | - all path errors are now reraised as PathIOError 254 | - server does not drop connection on path io errors since now, but return "451" code 255 | 256 | 0.3.1 (2015-11-09) 257 | ------------------ 258 | - fixed setup.py long-description 259 | 260 | 0.3.0 (2015-11-09) 261 | ------------------ 262 | - added handling of OSError in dispatcher 263 | - fixed client/server close not opened file in finally 264 | - handling PASS after login 265 | - handling miltiply USER commands 266 | - user manager for dealing with user accounts 267 | - fixed client usage WindowsPath instead of PurePosixPath on windows for virtual paths 268 | - client protected from "0.0.0.0" ip address in PASV 269 | - client use pathio 270 | - throttle deal with multiply connections 271 | - fixed throttle bug when slow path io (#20) 272 | - path io timeouts moved to pathio.py 273 | - with_timeout decorator for methods 274 | - StreamIO deals with timeouts 275 | - all socket streams are ThrottleStreamIO since now 276 | Thanks to `rsichny `_, `tier2003 `_ 277 | 278 | 0.2.0 (2015-09-22) 279 | ------------------ 280 | - client throttle 281 | - new server dispatcher (can wait for connections) 282 | - maximum connections per user/server 283 | - new client stream api 284 | - end of line character "\r\n" everywhere 285 | - setup.py support 286 | - tests via "python setup.py test" 287 | - "sh" module removed from test requirements 288 | Thanks to `rsichny `_, `jettify `_ 289 | 290 | 0.1.7 (2015-09-03) 291 | ------------------ 292 | - bugfix on windows (can't make passive connection to 0.0.0.0:port) 293 | - default host is "127.0.0.1" since now 294 | - silently ignoring ipv6 sockets in server binding list 295 | 296 | 0.1.6 (2015-09-03) 297 | ------------------ 298 | - bugfix on windows (ipv6 address come first in list of binded sockets) 299 | 300 | 0.1.5 (2015-09-01) 301 | ------------------ 302 | - bugfix server on windows (PurePosixPath for virtual path) 303 | 304 | 0.1.4 (2015-08-31) 305 | ------------------ 306 | - close data connection after client disconnects 307 | Thanks to `rsichny `_ 308 | 309 | 0.1.3 (2015-08-28) 310 | ------------------ 311 | - pep8 "Method definitions inside a class are surrounded by a single blank line" 312 | - MemoryPathIO.Stats should include st_mode 313 | Thanks to `rsichny `_ 314 | 315 | 0.1.2 (2015-06-11) 316 | ------------------ 317 | - aioftp now executes like script ("python -m aioftp") 318 | 319 | 0.1.1 (2015-06-10) 320 | ------------------ 321 | - typos in server strings 322 | - docstrings for path abstraction layer 323 | 324 | 0.1.0 (2015-06-05) 325 | ------------------ 326 | - server functionality 327 | - path abstraction layer 328 | 329 | 0.0.1 (2015-04-24) 330 | ------------------ 331 | - first release (client only) 332 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aioftp" 3 | version = "0.25.0" 4 | description = "ftp client/server for asyncio" 5 | readme = "README.rst" 6 | requires-python = ">= 3.9" 7 | license = {file = "license.txt"} 8 | authors = [ 9 | {name = "pohmelie", email = "multisosnooley@gmail.com"}, 10 | {name = "yieyu"}, 11 | {name = "rsichnyi"}, 12 | {name = "jw4js"}, 13 | {name = "asvetlov", email = "andrew.svetlov@gmail.com"}, 14 | {name = "decaz", email = "decaz89@gmail.com"}, 15 | {name = "oleksandr-kuzmenko"}, 16 | {name = "ndhansen"}, 17 | {name = "janneronkko", email="janne.ronkko@iki.fi"}, 18 | {name = "thirtyseven", email="ted@shlashdot.org"}, 19 | {name = "modelmat"}, 20 | {name = "greut"}, 21 | {name = "ported-pw", email="contact@ported.pw"}, 22 | {name = "PonyPC"}, 23 | {name = "jacobtomlinson"}, 24 | {name = "Olegt0rr", email="t0rr@mail.ru"}, 25 | {name = "michalc", email="michal@charemza.name"}, 26 | {name = "bachya"}, 27 | {name = "ch3pjw", email="paul@concertdaw.co.uk"}, 28 | {name = "puddly", email="puddly3@gmail.com"}, 29 | {name = "CrafterKolyan"}, 30 | {name = "jkr78"}, 31 | {name = "AMDmi3", email="amdmi3@amdmi3.ru"}, 32 | {name = "webknjaz", email="webknjaz+github/profile@redhat.com"}, 33 | {name = "rcfox", email="ryan@rcfox.ca"}, 34 | {name = "bellini666", email="thiago@bellini.dev"}, 35 | ] 36 | classifiers = [ 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 3", 39 | "Development Status :: 5 - Production/Stable", 40 | "Topic :: Internet :: File Transfer Protocol (FTP)", 41 | ] 42 | 43 | [project.urls] 44 | Github = "https://github.com/aio-libs/aioftp" 45 | Documentation = "https://aioftp.rtfd.io" 46 | 47 | [project.optional-dependencies] 48 | socks = [ 49 | "siosocks >= 0.2.0", 50 | ] 51 | dev = [ 52 | # tests 53 | "async_timeout >= 4.0.0", 54 | "pytest-asyncio", 55 | "pytest-cov", 56 | "pytest-mock", 57 | "pytest", 58 | "siosocks", 59 | "trustme", 60 | 61 | # linters 62 | "pre-commit", 63 | "ruff", 64 | ] 65 | 66 | [build-system] 67 | requires = ["setuptools", "wheel"] 68 | build-backend = "setuptools.build_meta" 69 | 70 | [tool.setuptools] 71 | packages.find.where = ["src"] 72 | 73 | # tools 74 | [tool.ruff] 75 | line-length = 120 76 | target-version = "py39" 77 | lint.select = ["E", "W", "F", "Q", "UP", "I", "ASYNC"] 78 | src = ["src"] 79 | 80 | [tool.coverage] 81 | run.source = ["./src/aioftp"] 82 | run.omit = ["./src/aioftp/__main__.py"] 83 | report.show_missing = true 84 | report.precision = 2 85 | 86 | [tool.pytest.ini_options] 87 | addopts = [ 88 | "-x", 89 | "--durations", "10", 90 | "-p", "no:anyio", 91 | "--cov", 92 | "--import-mode=importlib", 93 | ] 94 | testpaths = "tests" 95 | log_format = "%(asctime)s.%(msecs)03d %(name)-20s %(levelname)-8s %(filename)-15s %(lineno)-4d %(message)s" 96 | log_date_format = "%H:%M:%S" 97 | log_level = "DEBUG" 98 | asyncio_mode = "strict" 99 | -------------------------------------------------------------------------------- /src/aioftp/__init__.py: -------------------------------------------------------------------------------- 1 | """ftp client/server for asyncio""" 2 | 3 | # flake8: noqa 4 | 5 | import importlib.metadata 6 | 7 | from .client import * 8 | from .common import * 9 | from .errors import * 10 | from .pathio import * 11 | from .server import * 12 | 13 | __version__ = importlib.metadata.version(__package__) 14 | version = tuple(map(int, __version__.split("."))) 15 | 16 | __all__ = ( 17 | client.__all__ + server.__all__ + errors.__all__ + common.__all__ + pathio.__all__ + ("version", "__version__") 18 | ) 19 | -------------------------------------------------------------------------------- /src/aioftp/__main__.py: -------------------------------------------------------------------------------- 1 | """Simple aioftp-based server with one user (anonymous or not)""" 2 | 3 | import argparse 4 | import asyncio 5 | import contextlib 6 | import logging 7 | import socket 8 | 9 | import aioftp 10 | 11 | parser = argparse.ArgumentParser( 12 | prog="aioftp", 13 | usage="%(prog)s [options]", 14 | description="Simple aioftp-based server with one user (anonymous or not).", 15 | ) 16 | parser.add_argument( 17 | "--user", 18 | metavar="LOGIN", 19 | dest="login", 20 | help="user name to login", 21 | ) 22 | parser.add_argument( 23 | "--pass", 24 | metavar="PASSWORD", 25 | dest="password", 26 | help="password to login", 27 | ) 28 | parser.add_argument( 29 | "-d", 30 | metavar="DIRECTORY", 31 | dest="home", 32 | help="the directory to share (default current directory)", 33 | ) 34 | parser.add_argument( 35 | "-q", 36 | "--quiet", 37 | action="store_true", 38 | help="set logging level to 'ERROR' instead of 'INFO'", 39 | ) 40 | parser.add_argument("--memory", action="store_true", help="use memory storage") 41 | parser.add_argument( 42 | "--host", 43 | default=None, 44 | help="host for binding [default: %(default)s]", 45 | ) 46 | parser.add_argument( 47 | "--port", 48 | type=int, 49 | default=2121, 50 | help="port for binding [default: %(default)s]", 51 | ) 52 | parser.add_argument( 53 | "--family", 54 | choices=("ipv4", "ipv6", "auto"), 55 | default="auto", 56 | help="Socket family [default: %(default)s]", 57 | ) 58 | 59 | args = parser.parse_args() 60 | print(f"aioftp v{aioftp.__version__}") 61 | 62 | if not args.quiet: 63 | logging.basicConfig( 64 | level=logging.INFO, 65 | format="%(asctime)s [%(name)s] %(message)s", 66 | datefmt="[%H:%M:%S]:", 67 | ) 68 | if args.memory: 69 | user = aioftp.User(args.login, args.password, base_path="/") 70 | path_io_factory = aioftp.MemoryPathIO 71 | else: 72 | if args.home: 73 | user = aioftp.User(args.login, args.password, base_path=args.home) 74 | else: 75 | user = aioftp.User(args.login, args.password) 76 | path_io_factory = aioftp.PathIO 77 | family = { 78 | "ipv4": socket.AF_INET, 79 | "ipv6": socket.AF_INET6, 80 | "auto": socket.AF_UNSPEC, 81 | }[args.family] 82 | 83 | 84 | async def main(): 85 | server = aioftp.Server([user], path_io_factory=path_io_factory) 86 | await server.run(args.host, args.port, family=family) 87 | 88 | 89 | with contextlib.suppress(KeyboardInterrupt): 90 | asyncio.run(main()) 91 | -------------------------------------------------------------------------------- /src/aioftp/common.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio 3 | import collections 4 | import functools 5 | import locale 6 | import socket 7 | import ssl 8 | import threading 9 | from contextlib import contextmanager 10 | 11 | __all__ = ( 12 | "with_timeout", 13 | "StreamIO", 14 | "Throttle", 15 | "StreamThrottle", 16 | "ThrottleStreamIO", 17 | "END_OF_LINE", 18 | "DEFAULT_BLOCK_SIZE", 19 | "wrap_with_container", 20 | "AsyncStreamIterator", 21 | "AbstractAsyncLister", 22 | "AsyncListerMixin", 23 | "async_enterable", 24 | "DEFAULT_PORT", 25 | "DEFAULT_USER", 26 | "DEFAULT_PASSWORD", 27 | "DEFAULT_ACCOUNT", 28 | "setlocale", 29 | "SSLSessionBoundContext", 30 | ) 31 | 32 | END_OF_LINE = "\r\n" 33 | DEFAULT_BLOCK_SIZE = 8192 34 | 35 | DEFAULT_PORT = 21 36 | DEFAULT_USER = "anonymous" 37 | DEFAULT_PASSWORD = "anon@" 38 | DEFAULT_ACCOUNT = "" 39 | HALF_OF_YEAR_IN_SECONDS = 15778476 40 | TWO_YEARS_IN_SECONDS = ((365 * 3 + 366) * 24 * 60 * 60) / 2 41 | 42 | 43 | def _now(): 44 | return asyncio.get_running_loop().time() 45 | 46 | 47 | def _with_timeout(name): 48 | def decorator(f): 49 | @functools.wraps(f) 50 | def wrapper(cls, *args, **kwargs): 51 | coro = f(cls, *args, **kwargs) 52 | timeout = getattr(cls, name) 53 | return asyncio.wait_for(coro, timeout) 54 | 55 | return wrapper 56 | 57 | return decorator 58 | 59 | 60 | def with_timeout(name): 61 | """ 62 | Method decorator, wraps method with :py:func:`asyncio.wait_for`. `timeout` 63 | argument takes from `name` decorator argument or "timeout". 64 | 65 | :param name: name of timeout attribute 66 | :type name: :py:class:`str` 67 | 68 | :raises asyncio.TimeoutError: if coroutine does not finished in timeout 69 | 70 | Wait for `self.timeout` 71 | :: 72 | 73 | >>> def __init__(self, ...): 74 | ... 75 | ... self.timeout = 1 76 | ... 77 | ... @with_timeout 78 | ... async def foo(self, ...): 79 | ... 80 | ... pass 81 | 82 | Wait for custom timeout 83 | :: 84 | 85 | >>> def __init__(self, ...): 86 | ... 87 | ... self.foo_timeout = 1 88 | ... 89 | ... @with_timeout("foo_timeout") 90 | ... async def foo(self, ...): 91 | ... 92 | ... pass 93 | 94 | """ 95 | 96 | if isinstance(name, str): 97 | return _with_timeout(name) 98 | else: 99 | return _with_timeout("timeout")(name) 100 | 101 | 102 | class AsyncStreamIterator: 103 | def __init__(self, read_coro): 104 | self.read_coro = read_coro 105 | 106 | def __aiter__(self): 107 | return self 108 | 109 | async def __anext__(self): 110 | data = await self.read_coro() 111 | if data: 112 | return data 113 | else: 114 | raise StopAsyncIteration 115 | 116 | 117 | class AsyncListerMixin: 118 | """ 119 | Add ability to `async for` context to collect data to list via await. 120 | 121 | :: 122 | 123 | >>> class Context(AsyncListerMixin): 124 | ... ... 125 | >>> results = await Context(...) 126 | """ 127 | 128 | async def _to_list(self): 129 | items = [] 130 | async for item in self: 131 | items.append(item) 132 | return items 133 | 134 | def __await__(self): 135 | return self._to_list().__await__() 136 | 137 | 138 | class AbstractAsyncLister(AsyncListerMixin, abc.ABC): 139 | """ 140 | Abstract context with ability to collect all iterables into 141 | :py:class:`list` via `await` with optional timeout (via 142 | :py:func:`aioftp.with_timeout`) 143 | 144 | :param timeout: timeout for __anext__ operation 145 | :type timeout: :py:class:`None`, :py:class:`int` or :py:class:`float` 146 | 147 | :: 148 | 149 | >>> class Lister(AbstractAsyncLister): 150 | ... 151 | ... @with_timeout 152 | ... async def __anext__(self): 153 | ... ... 154 | 155 | :: 156 | 157 | >>> async for block in Lister(...): 158 | ... ... 159 | 160 | :: 161 | 162 | >>> result = await Lister(...) 163 | >>> result 164 | [block, block, block, ...] 165 | """ 166 | 167 | def __init__(self, *, timeout=None): 168 | super().__init__() 169 | self.timeout = timeout 170 | 171 | def __aiter__(self): 172 | return self 173 | 174 | @with_timeout 175 | @abc.abstractmethod 176 | async def __anext__(self): 177 | """ 178 | :py:func:`asyncio.coroutine` 179 | 180 | Abstract method 181 | """ 182 | 183 | 184 | def async_enterable(f): 185 | """ 186 | Decorator. Bring coroutine result up, so it can be used as async context 187 | 188 | :: 189 | 190 | >>> async def foo(): 191 | ... 192 | ... ... 193 | ... return AsyncContextInstance(...) 194 | ... 195 | ... ctx = await foo() 196 | ... async with ctx: 197 | ... 198 | ... # do 199 | 200 | :: 201 | 202 | >>> @async_enterable 203 | ... async def foo(): 204 | ... 205 | ... ... 206 | ... return AsyncContextInstance(...) 207 | ... 208 | ... async with foo() as ctx: 209 | ... 210 | ... # do 211 | ... 212 | ... ctx = await foo() 213 | ... async with ctx: 214 | ... 215 | ... # do 216 | 217 | """ 218 | 219 | @functools.wraps(f) 220 | def wrapper(*args, **kwargs): 221 | class AsyncEnterableInstance: 222 | async def __aenter__(self): 223 | self.context = await f(*args, **kwargs) 224 | return await self.context.__aenter__() 225 | 226 | async def __aexit__(self, *args, **kwargs): 227 | await self.context.__aexit__(*args, **kwargs) 228 | 229 | def __await__(self): 230 | return f(*args, **kwargs).__await__() 231 | 232 | return AsyncEnterableInstance() 233 | 234 | return wrapper 235 | 236 | 237 | def wrap_with_container(o): 238 | if isinstance(o, str): 239 | o = (o,) 240 | return o 241 | 242 | 243 | class StreamIO: 244 | """ 245 | Stream input/output wrapper with timeout. 246 | 247 | :param reader: stream reader 248 | :type reader: :py:class:`asyncio.StreamReader` 249 | 250 | :param writer: stream writer 251 | :type writer: :py:class:`asyncio.StreamWriter` 252 | 253 | :param timeout: socket timeout for read/write operations 254 | :type timeout: :py:class:`int`, :py:class:`float` or :py:class:`None` 255 | 256 | :param read_timeout: socket timeout for read operations, overrides 257 | `timeout` 258 | :type read_timeout: :py:class:`int`, :py:class:`float` or :py:class:`None` 259 | 260 | :param write_timeout: socket timeout for write operations, overrides 261 | `timeout` 262 | :type write_timeout: :py:class:`int`, :py:class:`float` or :py:class:`None` 263 | """ 264 | 265 | def __init__(self, reader, writer, *, timeout=None, read_timeout=None, write_timeout=None): 266 | self.reader = reader 267 | self.writer = writer 268 | self.read_timeout = read_timeout or timeout 269 | self.write_timeout = write_timeout or timeout 270 | 271 | @with_timeout("read_timeout") 272 | async def readline(self): 273 | """ 274 | :py:func:`asyncio.coroutine` 275 | 276 | Proxy for :py:meth:`asyncio.StreamReader.readline`. 277 | """ 278 | return await self.reader.readline() 279 | 280 | @with_timeout("read_timeout") 281 | async def read(self, count=-1): 282 | """ 283 | :py:func:`asyncio.coroutine` 284 | 285 | Proxy for :py:meth:`asyncio.StreamReader.read`. 286 | 287 | :param count: block size for read operation 288 | :type count: :py:class:`int` 289 | """ 290 | return await self.reader.read(count) 291 | 292 | @with_timeout("read_timeout") 293 | async def readexactly(self, count): 294 | """ 295 | :py:func:`asyncio.coroutine` 296 | 297 | Proxy for :py:meth:`asyncio.StreamReader.readexactly`. 298 | 299 | :param count: block size for read operation 300 | :type count: :py:class:`int` 301 | """ 302 | return await self.reader.readexactly(count) 303 | 304 | @with_timeout("write_timeout") 305 | async def write(self, data): 306 | """ 307 | :py:func:`asyncio.coroutine` 308 | 309 | Combination of :py:meth:`asyncio.StreamWriter.write` and 310 | :py:meth:`asyncio.StreamWriter.drain`. 311 | 312 | :param data: data to write 313 | :type data: :py:class:`bytes` 314 | """ 315 | self.writer.write(data) 316 | await self.writer.drain() 317 | 318 | def close(self): 319 | """ 320 | Close connection. 321 | """ 322 | self.writer.close() 323 | 324 | async def start_tls(self, sslcontext, server_hostname): 325 | """ 326 | Upgrades the connection to TLS 327 | """ 328 | await self.writer.start_tls( 329 | sslcontext=sslcontext, 330 | server_hostname=server_hostname, 331 | ssl_handshake_timeout=self.write_timeout, 332 | ) 333 | 334 | 335 | class Throttle: 336 | """ 337 | Throttle for streams. 338 | 339 | :param limit: speed limit in bytes or :py:class:`None` for unlimited 340 | :type limit: :py:class:`int` or :py:class:`None` 341 | 342 | :param reset_rate: time in seconds for «round» throttle memory (to deal 343 | with float precision when divide) 344 | :type reset_rate: :py:class:`int` or :py:class:`float` 345 | """ 346 | 347 | def __init__(self, *, limit=None, reset_rate=10): 348 | self._limit = limit 349 | self.reset_rate = reset_rate 350 | self._start = None 351 | self._sum = 0 352 | 353 | async def wait(self): 354 | """ 355 | :py:func:`asyncio.coroutine` 356 | 357 | Wait until can do IO 358 | """ 359 | if self._limit is not None and self._limit > 0 and self._start is not None: 360 | now = _now() 361 | end = self._start + self._sum / self._limit 362 | await asyncio.sleep(max(0, end - now)) 363 | 364 | def append(self, data, start): 365 | """ 366 | Count `data` for throttle 367 | 368 | :param data: bytes of data for count 369 | :type data: :py:class:`bytes` 370 | 371 | :param start: start of read/write time from 372 | :py:meth:`asyncio.BaseEventLoop.time` 373 | :type start: :py:class:`float` 374 | """ 375 | if self._limit is not None and self._limit > 0: 376 | if self._start is None: 377 | self._start = start 378 | if start - self._start > self.reset_rate: 379 | self._sum -= round((start - self._start) * self._limit) 380 | self._start = start 381 | self._sum += len(data) 382 | 383 | @property 384 | def limit(self): 385 | """ 386 | Throttle limit 387 | """ 388 | return self._limit 389 | 390 | @limit.setter 391 | def limit(self, value): 392 | """ 393 | Set throttle limit 394 | 395 | :param value: bytes per second 396 | :type value: :py:class:`int` or :py:class:`None` 397 | """ 398 | self._limit = value 399 | self._start = None 400 | self._sum = 0 401 | 402 | def clone(self): 403 | """ 404 | Clone throttle without memory 405 | """ 406 | return Throttle(limit=self._limit, reset_rate=self.reset_rate) 407 | 408 | def __repr__(self): 409 | return f"{self.__class__.__name__}(limit={self._limit!r}, reset_rate={self.reset_rate!r})" 410 | 411 | 412 | class StreamThrottle(collections.namedtuple("StreamThrottle", "read write")): 413 | """ 414 | Stream throttle with `read` and `write` :py:class:`aioftp.Throttle` 415 | 416 | :param read: stream read throttle 417 | :type read: :py:class:`aioftp.Throttle` 418 | 419 | :param write: stream write throttle 420 | :type write: :py:class:`aioftp.Throttle` 421 | """ 422 | 423 | def clone(self): 424 | """ 425 | Clone throttles without memory 426 | """ 427 | return StreamThrottle( 428 | read=self.read.clone(), 429 | write=self.write.clone(), 430 | ) 431 | 432 | @classmethod 433 | def from_limits(cls, read_speed_limit=None, write_speed_limit=None): 434 | """ 435 | Simple wrapper for creation :py:class:`aioftp.StreamThrottle` 436 | 437 | :param read_speed_limit: stream read speed limit in bytes or 438 | :py:class:`None` for unlimited 439 | :type read_speed_limit: :py:class:`int` or :py:class:`None` 440 | 441 | :param write_speed_limit: stream write speed limit in bytes or 442 | :py:class:`None` for unlimited 443 | :type write_speed_limit: :py:class:`int` or :py:class:`None` 444 | """ 445 | return cls( 446 | read=Throttle(limit=read_speed_limit), 447 | write=Throttle(limit=write_speed_limit), 448 | ) 449 | 450 | 451 | class ThrottleStreamIO(StreamIO): 452 | """ 453 | Throttled :py:class:`aioftp.StreamIO`. `ThrottleStreamIO` is subclass of 454 | :py:class:`aioftp.StreamIO`. `throttles` attribute is dictionary of `name`: 455 | :py:class:`aioftp.StreamThrottle` pairs 456 | 457 | :param *args: positional arguments for :py:class:`aioftp.StreamIO` 458 | :param **kwargs: keyword arguments for :py:class:`aioftp.StreamIO` 459 | 460 | :param throttles: dictionary of throttles 461 | :type throttles: :py:class:`dict` with :py:class:`aioftp.Throttle` values 462 | 463 | :: 464 | 465 | >>> self.stream = ThrottleStreamIO( 466 | ... reader, 467 | ... writer, 468 | ... throttles={ 469 | ... "main": StreamThrottle( 470 | ... read=Throttle(...), 471 | ... write=Throttle(...) 472 | ... ) 473 | ... }, 474 | ... timeout=timeout 475 | ... ) 476 | """ 477 | 478 | def __init__(self, *args, throttles={}, **kwargs): 479 | super().__init__(*args, **kwargs) 480 | self.throttles = throttles 481 | 482 | async def wait(self, name): 483 | """ 484 | :py:func:`asyncio.coroutine` 485 | 486 | Wait for all throttles 487 | 488 | :param name: name of throttle to acquire ("read" or "write") 489 | :type name: :py:class:`str` 490 | """ 491 | tasks = [] 492 | for throttle in self.throttles.values(): 493 | curr_throttle = getattr(throttle, name) 494 | if curr_throttle.limit: 495 | tasks.append(asyncio.create_task(curr_throttle.wait())) 496 | if tasks: 497 | await asyncio.wait(tasks) 498 | 499 | def append(self, name, data, start): 500 | """ 501 | Update timeout for all throttles 502 | 503 | :param name: name of throttle to append to ("read" or "write") 504 | :type name: :py:class:`str` 505 | 506 | :param data: bytes of data for count 507 | :type data: :py:class:`bytes` 508 | 509 | :param start: start of read/write time from 510 | :py:meth:`asyncio.BaseEventLoop.time` 511 | :type start: :py:class:`float` 512 | """ 513 | for throttle in self.throttles.values(): 514 | getattr(throttle, name).append(data, start) 515 | 516 | async def read(self, count=-1): 517 | """ 518 | :py:func:`asyncio.coroutine` 519 | 520 | :py:meth:`aioftp.StreamIO.read` proxy 521 | """ 522 | await self.wait("read") 523 | start = _now() 524 | data = await super().read(count) 525 | self.append("read", data, start) 526 | return data 527 | 528 | async def readline(self): 529 | """ 530 | :py:func:`asyncio.coroutine` 531 | 532 | :py:meth:`aioftp.StreamIO.readline` proxy 533 | """ 534 | await self.wait("read") 535 | start = _now() 536 | data = await super().readline() 537 | self.append("read", data, start) 538 | return data 539 | 540 | async def write(self, data): 541 | """ 542 | :py:func:`asyncio.coroutine` 543 | 544 | :py:meth:`aioftp.StreamIO.write` proxy 545 | """ 546 | await self.wait("write") 547 | start = _now() 548 | await super().write(data) 549 | self.append("write", data, start) 550 | 551 | async def __aenter__(self): 552 | return self 553 | 554 | async def __aexit__(self, *args): 555 | self.close() 556 | 557 | def iter_by_line(self): 558 | """ 559 | Read/iterate stream by line. 560 | 561 | :rtype: :py:class:`aioftp.AsyncStreamIterator` 562 | 563 | :: 564 | 565 | >>> async for line in stream.iter_by_line(): 566 | ... ... 567 | """ 568 | return AsyncStreamIterator(self.readline) 569 | 570 | def iter_by_block(self, count=DEFAULT_BLOCK_SIZE): 571 | """ 572 | Read/iterate stream by block. 573 | 574 | :rtype: :py:class:`aioftp.AsyncStreamIterator` 575 | 576 | :: 577 | 578 | >>> async for block in stream.iter_by_block(block_size): 579 | ... ... 580 | """ 581 | return AsyncStreamIterator(lambda: self.read(count)) 582 | 583 | 584 | LOCALE_LOCK = threading.Lock() 585 | 586 | 587 | @contextmanager 588 | def setlocale(name): 589 | """ 590 | Context manager with threading lock for set locale on enter, and set it 591 | back to original state on exit. 592 | 593 | :: 594 | 595 | >>> with setlocale("C"): 596 | ... ... 597 | """ 598 | with LOCALE_LOCK: 599 | old_locale = locale.setlocale(locale.LC_ALL) 600 | try: 601 | yield locale.setlocale(locale.LC_ALL, name) 602 | finally: 603 | locale.setlocale(locale.LC_ALL, old_locale) 604 | 605 | 606 | # class from https://github.com/python/cpython/issues/79152 (with some changes) 607 | class SSLSessionBoundContext(ssl.SSLContext): 608 | """ssl.SSLContext bound to an existing SSL session. 609 | 610 | Actually asyncio doesn't support TLS session resumption, the loop.create_connection() API 611 | does not take any TLS session related argument. There is ongoing work to add support for this 612 | at https://github.com/python/cpython/issues/79152. 613 | 614 | The loop.create_connection() API takes a SSL context argument though, the SSLSessionBoundContext 615 | is used to wrap a SSL context and inject a SSL session on calls to 616 | - SSLSessionBoundContext.wrap_socket() 617 | - SSLSessionBoundContext.wrap_bio() 618 | 619 | This wrapper is compatible with any TLS application which calls only the methods above when 620 | making new TLS connections. This class is NOT a subclass of ssl.SSLContext, so it will be 621 | rejected by applications which ensure the SSL context is an instance of ssl.SSLContext. Not being 622 | a subclass of ssl.SSLContext makes this wrapper lightweight. 623 | """ 624 | 625 | __slots__ = ("context", "session") 626 | 627 | def __init__(self, protocol: int, context: ssl.SSLContext, session: ssl.SSLSession): 628 | self.context = context 629 | self.session = session 630 | 631 | def wrap_socket( 632 | self, 633 | sock: socket.socket, 634 | server_side: bool = False, 635 | do_handshake_on_connect: bool = True, 636 | suppress_ragged_eofs: bool = True, 637 | server_hostname: bool = None, 638 | session: ssl.SSLSession = None, 639 | ) -> ssl.SSLSocket: 640 | if session is not None: 641 | raise ValueError("expected session to be None") 642 | return self.context.wrap_socket( 643 | sock=sock, 644 | server_hostname=server_hostname, 645 | server_side=server_side, 646 | do_handshake_on_connect=do_handshake_on_connect, 647 | suppress_ragged_eofs=suppress_ragged_eofs, 648 | session=self.session, 649 | ) 650 | 651 | def wrap_bio( 652 | self, 653 | incoming: ssl.MemoryBIO, 654 | outgoing: ssl.MemoryBIO, 655 | server_side: bool = False, 656 | server_hostname: bool = None, 657 | session: ssl.SSLSession = None, 658 | ) -> ssl.SSLObject: 659 | if session is not None: 660 | raise ValueError("expected session to be None") 661 | return self.context.wrap_bio( 662 | incoming=incoming, 663 | outgoing=outgoing, 664 | server_hostname=server_hostname, 665 | server_side=server_side, 666 | session=self.session, 667 | ) 668 | -------------------------------------------------------------------------------- /src/aioftp/errors.py: -------------------------------------------------------------------------------- 1 | from . import common 2 | 3 | __all__ = ( 4 | "AIOFTPException", 5 | "StatusCodeError", 6 | "PathIsNotAbsolute", 7 | "PathIOError", 8 | "NoAvailablePort", 9 | ) 10 | 11 | 12 | class AIOFTPException(Exception): 13 | """ 14 | Base exception class. 15 | """ 16 | 17 | 18 | class StatusCodeError(AIOFTPException): 19 | """ 20 | Raised for unexpected or "bad" status codes. 21 | 22 | :param expected_codes: tuple of expected codes or expected code 23 | :type expected_codes: :py:class:`tuple` of :py:class:`aioftp.Code` or 24 | :py:class:`aioftp.Code` 25 | 26 | :param received_codes: tuple of received codes or received code 27 | :type received_codes: :py:class:`tuple` of :py:class:`aioftp.Code` or 28 | :py:class:`aioftp.Code` 29 | 30 | :param info: list of lines with server response 31 | :type info: :py:class:`list` of :py:class:`str` 32 | 33 | :: 34 | 35 | >>> try: 36 | ... # something with aioftp 37 | ... except StatusCodeError as e: 38 | ... print(e.expected_codes, e.received_codes, e.info) 39 | ... # analyze state 40 | 41 | Exception members are tuples, even for one code. 42 | """ 43 | 44 | def __init__(self, expected_codes, received_codes, info): 45 | super().__init__( 46 | f"Waiting for {expected_codes} but got {received_codes} {info!r}", 47 | ) 48 | self.expected_codes = common.wrap_with_container(expected_codes) 49 | self.received_codes = common.wrap_with_container(received_codes) 50 | self.info = info 51 | 52 | 53 | class PathIsNotAbsolute(AIOFTPException): 54 | """ 55 | Raised when "path" is not absolute. 56 | """ 57 | 58 | 59 | class PathIOError(AIOFTPException): 60 | """ 61 | Universal exception for any path io errors. 62 | 63 | :: 64 | 65 | >>> try: 66 | ... # some client/server path operation 67 | ... except PathIOError as exc: 68 | ... type, value, traceback = exc.reason 69 | ... if isinstance(value, SomeException): 70 | ... # handle 71 | ... elif ... 72 | ... # handle 73 | """ 74 | 75 | def __init__(self, *args, reason=None, **kwargs): 76 | super().__init__(*args, **kwargs) 77 | self.reason = reason 78 | 79 | 80 | class NoAvailablePort(AIOFTPException, OSError): 81 | """ 82 | Raised when there is no available data port 83 | """ 84 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import contextlib 4 | import functools 5 | import math 6 | import socket 7 | import ssl 8 | import tempfile 9 | import time 10 | from pathlib import Path 11 | 12 | import pytest 13 | import pytest_asyncio 14 | import trustme 15 | from async_timeout import timeout 16 | from siosocks.io.asyncio import socks_server_handler 17 | 18 | import aioftp 19 | 20 | # No ssl tests since https://bugs.python.org/issue36098 21 | ca = trustme.CA() 22 | server_cert = ca.issue_server_cert("127.0.0.1", "::1") 23 | 24 | ssl_server = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 25 | server_cert.configure_cert(ssl_server) 26 | 27 | ssl_client = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) 28 | ca.configure_trust(ssl_client) 29 | 30 | 31 | class Container: 32 | def __init__(self, *args, **kwargs): 33 | self.args = args 34 | self.kwargs = kwargs 35 | 36 | 37 | @pytest.fixture 38 | def Client(): 39 | return Container 40 | 41 | 42 | @pytest.fixture 43 | def Server(): 44 | return Container 45 | 46 | 47 | def _wrap_with_defaults(kwargs): 48 | test_defaults = dict( 49 | path_io_factory=aioftp.MemoryPathIO, 50 | ) 51 | return collections.ChainMap(kwargs, test_defaults) 52 | 53 | 54 | @pytest.fixture(params=["127.0.0.1", "::1"]) 55 | def pair_factory(request): 56 | class Factory: 57 | def __init__( 58 | self, 59 | client=None, 60 | server=None, 61 | *, 62 | connected=True, 63 | logged=True, 64 | do_quit=True, 65 | host=request.param, 66 | server_factory=aioftp.Server, 67 | client_factory=aioftp.Client, 68 | ): 69 | if client is None: 70 | client = Container() 71 | self.client = client_factory(*client.args, **_wrap_with_defaults(client.kwargs)) 72 | if server is None: 73 | server = Container() 74 | self.server = server_factory(*server.args, **_wrap_with_defaults(server.kwargs)) 75 | self.connected = connected 76 | self.logged = logged 77 | self.do_quit = do_quit 78 | self.host = host 79 | self.timeout = timeout(1) 80 | 81 | async def make_server_files(self, *paths, size=None, atom=b"-"): 82 | if size is None: 83 | size = aioftp.DEFAULT_BLOCK_SIZE * 3 84 | data = atom * size 85 | for p in paths: 86 | await self.client.make_directory(Path(p).parent) 87 | async with self.client.upload_stream(p) as stream: 88 | await stream.write(data) 89 | 90 | async def make_client_files(self, *paths, size=None, atom=b"-"): 91 | if size is None: 92 | size = aioftp.DEFAULT_BLOCK_SIZE * 3 93 | data = atom * size 94 | for p in map(Path, paths): 95 | await self.client.path_io.mkdir( 96 | p.parent, 97 | parents=True, 98 | exist_ok=True, 99 | ) 100 | async with self.client.path_io.open(p, mode="wb") as f: 101 | await f.write(data) 102 | 103 | async def server_paths_exists(self, *paths): 104 | values = [] 105 | for p in paths: 106 | values.append(await self.client.exists(p)) 107 | if all(values): 108 | return True 109 | if any(values): 110 | raise ValueError("Mixed exists/not exists list") 111 | return False 112 | 113 | async def client_paths_exists(self, *paths): 114 | values = [] 115 | for p in paths: 116 | values.append(await self.client.path_io.exists(Path(p))) 117 | if all(values): 118 | return True 119 | if any(values): 120 | raise ValueError("Mixed exists/not exists list") 121 | return False 122 | 123 | async def __aenter__(self): 124 | await self.timeout.__aenter__() 125 | await self.server.start(host=self.host) 126 | if self.connected: 127 | await self.client.connect( 128 | self.server.server_host, 129 | self.server.server_port, 130 | ) 131 | if self.logged: 132 | await self.client.login() 133 | return self 134 | 135 | async def __aexit__(self, *exc_info): 136 | if self.connected and self.do_quit: 137 | await self.client.quit() 138 | self.client.close() 139 | await self.server.close() 140 | await self.timeout.__aexit__(*exc_info) 141 | 142 | return Factory 143 | 144 | 145 | @pytest.fixture 146 | def expect_codes_in_exception(): 147 | @contextlib.contextmanager 148 | def context(*codes): 149 | try: 150 | yield 151 | except aioftp.StatusCodeError as e: 152 | assert set(e.received_codes) == set(codes) 153 | else: 154 | raise RuntimeError("There was no exception") 155 | 156 | return context 157 | 158 | 159 | @pytest.fixture( 160 | params=[ 161 | aioftp.MemoryPathIO, 162 | aioftp.PathIO, 163 | aioftp.AsyncPathIO, 164 | ], 165 | ) 166 | def path_io(request): 167 | return request.param() 168 | 169 | 170 | @pytest.fixture 171 | def temp_dir(path_io): 172 | if isinstance(path_io, aioftp.MemoryPathIO): 173 | yield Path("/") 174 | else: 175 | with tempfile.TemporaryDirectory() as name: 176 | yield Path(name) 177 | 178 | 179 | class Sleep: 180 | def __init__(self): 181 | self.delay = 0 182 | self.first_sleep = None 183 | 184 | async def sleep(self, delay, result=None, **kwargs): 185 | if self.first_sleep is None: 186 | self.first_sleep = time.monotonic() 187 | delay = (time.monotonic() - self.first_sleep) + delay 188 | self.delay = max(self.delay, delay) 189 | return result 190 | 191 | def is_close(self, delay, *, rel_tol=0.05, abs_tol=0.5): 192 | ok = math.isclose(self.delay, delay, rel_tol=rel_tol, abs_tol=abs_tol) 193 | if not ok: 194 | print( 195 | f"latest sleep: {self.delay}; expected delay: {delay}; rel: {rel_tol}", 196 | ) 197 | return ok 198 | 199 | 200 | @pytest.fixture 201 | def skip_sleep(monkeypatch): 202 | with monkeypatch.context() as m: 203 | sleeper = Sleep() 204 | m.setattr(asyncio, "sleep", sleeper.sleep) 205 | yield sleeper 206 | 207 | 208 | @pytest_asyncio.fixture( 209 | params=[ 210 | ("127.0.0.1", socket.AF_INET), 211 | ("::1", socket.AF_INET6), 212 | ], 213 | ) 214 | async def socks(request, unused_tcp_port): 215 | handler = functools.partial( 216 | socks_server_handler, 217 | allowed_versions={5}, 218 | username="foo", 219 | password="bar", 220 | ) 221 | Socks = collections.namedtuple("Socks", "host port server") 222 | host, family = request.param 223 | port = unused_tcp_port 224 | server = await asyncio.start_server( 225 | handler, 226 | host=host, 227 | port=port, 228 | family=family, 229 | ) 230 | yield Socks(host, port, server) 231 | server.close() 232 | await server.wait_closed() 233 | -------------------------------------------------------------------------------- /tests/test_abort.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import itertools 3 | import pathlib 4 | 5 | import pytest 6 | 7 | import aioftp 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_abort_stor(pair_factory): 12 | async with pair_factory() as pair: 13 | stream = await pair.client.upload_stream("test.txt") 14 | with pytest.raises((ConnectionResetError, BrokenPipeError)): 15 | while True: 16 | await stream.write(b"-" * aioftp.DEFAULT_BLOCK_SIZE) 17 | await pair.client.abort() 18 | 19 | 20 | class SlowReadMemoryPathIO(aioftp.MemoryPathIO): 21 | async def read(self, *args, **kwargs): 22 | await asyncio.sleep(0.01) 23 | return await super().read(*args, **kwargs) 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_abort_retr(pair_factory, Server): 28 | s = Server(path_io_factory=SlowReadMemoryPathIO) 29 | async with pair_factory(None, s) as pair: 30 | await pair.make_server_files("test.txt") 31 | stream = await pair.client.download_stream("test.txt") 32 | for i in itertools.count(): 33 | data = await stream.read(aioftp.DEFAULT_BLOCK_SIZE) 34 | if not data: 35 | stream.close() 36 | break 37 | if i == 0: 38 | await pair.client.abort() 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_abort_retr_no_wait( 43 | pair_factory, 44 | Server, 45 | expect_codes_in_exception, 46 | ): 47 | s = Server(path_io_factory=SlowReadMemoryPathIO) 48 | async with pair_factory(None, s) as pair: 49 | await pair.make_server_files("test.txt") 50 | stream = await pair.client.download_stream("test.txt") 51 | with expect_codes_in_exception("426"): 52 | for i in itertools.count(): 53 | data = await stream.read(aioftp.DEFAULT_BLOCK_SIZE) 54 | if not data: 55 | await stream.finish() 56 | break 57 | if i == 0: 58 | await pair.client.abort(wait=False) 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_nothing_to_abort(pair_factory): 63 | async with pair_factory() as pair: 64 | await pair.client.abort() 65 | 66 | 67 | class SlowListMemoryPathIO(aioftp.MemoryPathIO): 68 | async def is_file(self, *a, **kw): 69 | return True 70 | 71 | def list(self, *args, **kwargs): 72 | class Lister(aioftp.AbstractAsyncLister): 73 | async def __anext__(cls): 74 | await asyncio.sleep(0.01) 75 | return pathlib.PurePath("/test.txt") 76 | 77 | return Lister() 78 | 79 | async def stat(self, *a, **kw): 80 | class Stat: 81 | st_size = 0 82 | st_mtime = 0 83 | st_ctime = 0 84 | 85 | return Stat 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_mlsd_abort(pair_factory, Server): 90 | s = Server(path_io_factory=SlowListMemoryPathIO) 91 | async with pair_factory(None, s) as pair: 92 | cwd = await pair.client.get_current_directory() 93 | assert cwd == pathlib.PurePosixPath("/") 94 | async for path, info in pair.client.list(): 95 | await pair.client.abort() 96 | break 97 | -------------------------------------------------------------------------------- /tests/test_client_side_socks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from siosocks.exceptions import SocksException 3 | 4 | 5 | @pytest.mark.asyncio 6 | async def test_socks_success(pair_factory, Client, socks): 7 | client = Client( 8 | socks_host=socks.host, 9 | socks_port=socks.port, 10 | socks_version=5, 11 | username="foo", 12 | password="bar", 13 | ) 14 | async with pair_factory(client): 15 | pass 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_socks_fail(pair_factory, Client, socks): 20 | client = Client( 21 | socks_host=socks.host, 22 | socks_port=socks.port, 23 | socks_version=5, 24 | username="bar", 25 | password="bar", 26 | ) 27 | with pytest.raises(SocksException): 28 | async with pair_factory(client): 29 | pass 30 | -------------------------------------------------------------------------------- /tests/test_connection.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | 4 | import pytest 5 | 6 | import aioftp 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_client_without_server(pair_factory, unused_tcp_port_factory): 11 | f = pair_factory(connected=False, logged=False, do_quit=False) 12 | async with f as pair: 13 | pass 14 | with pytest.raises(OSError): 15 | await pair.client.connect("127.0.0.1", unused_tcp_port_factory()) 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_connection(pair_factory): 20 | async with pair_factory(connected=True, logged=False, do_quit=False): 21 | pass 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_quit(pair_factory): 26 | async with pair_factory(connected=True, logged=False, do_quit=True): 27 | pass 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_not_implemented(pair_factory, expect_codes_in_exception): 32 | async with pair_factory() as pair: 33 | with expect_codes_in_exception("502"): 34 | await pair.client.command("FOOBAR", "2xx", "1xx") 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_type_success(pair_factory, expect_codes_in_exception): 39 | async with pair_factory() as pair: 40 | await pair.client.get_passive_connection("A") 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_custom_passive_commands(pair_factory): 45 | async with pair_factory(host="127.0.0.1") as pair: 46 | pair.client._passive_commands = None 47 | await pair.client.get_passive_connection( 48 | "A", 49 | commands=["pasv", "epsv"], 50 | ) 51 | 52 | 53 | @pytest.mark.asyncio 54 | async def test_extra_pasv_connection(pair_factory): 55 | async with pair_factory() as pair: 56 | r, w = await pair.client.get_passive_connection() 57 | er, ew = await pair.client.get_passive_connection() 58 | with pytest.raises((ConnectionResetError, BrokenPipeError)): 59 | while True: 60 | w.write(b"-" * aioftp.DEFAULT_BLOCK_SIZE) 61 | await w.drain() 62 | 63 | 64 | @pytest.mark.parametrize("method", ["epsv", "pasv"]) 65 | @pytest.mark.asyncio 66 | async def test_closing_passive_connection(pair_factory, method): 67 | async with pair_factory(host="127.0.0.1") as pair: 68 | r, w = await pair.client.get_passive_connection(commands=[method]) 69 | host, port, *_ = w.transport.get_extra_info("peername") 70 | nr, nw = await asyncio.open_connection(host, port) 71 | with pytest.raises((ConnectionResetError, BrokenPipeError)): 72 | while True: 73 | nw.write(b"-" * aioftp.DEFAULT_BLOCK_SIZE) 74 | await nw.drain() 75 | 76 | 77 | @pytest.mark.asyncio 78 | async def test_pasv_connection_ports_not_added(pair_factory): 79 | async with pair_factory() as pair: 80 | r, w = await pair.client.get_passive_connection() 81 | assert pair.server.available_data_ports is None 82 | 83 | 84 | @pytest.mark.asyncio 85 | async def test_pasv_connection_ports( 86 | pair_factory, 87 | Server, 88 | unused_tcp_port_factory, 89 | ): 90 | ports = [unused_tcp_port_factory(), unused_tcp_port_factory()] 91 | async with pair_factory(None, Server(data_ports=ports)) as pair: 92 | r, w = await pair.client.get_passive_connection() 93 | host, port, *_ = w.transport.get_extra_info("peername") 94 | assert port in ports 95 | assert pair.server.available_data_ports.qsize() == 1 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_data_ports_remains_empty(pair_factory, Server): 100 | async with pair_factory(None, Server(data_ports=[])) as pair: 101 | assert pair.server.available_data_ports.qsize() == 0 102 | 103 | 104 | @pytest.mark.asyncio 105 | async def test_pasv_connection_port_reused( 106 | pair_factory, 107 | Server, 108 | unused_tcp_port, 109 | ): 110 | s = Server(data_ports=[unused_tcp_port]) 111 | async with pair_factory(None, s) as pair: 112 | r, w = await pair.client.get_passive_connection() 113 | host, port, *_ = w.transport.get_extra_info("peername") 114 | assert port == unused_tcp_port 115 | assert pair.server.available_data_ports.qsize() == 0 116 | w.close() 117 | await pair.client.quit() 118 | pair.client.close() 119 | assert pair.server.available_data_ports.qsize() == 1 120 | await pair.client.connect( 121 | pair.server.server_host, 122 | pair.server.server_port, 123 | ) 124 | await pair.client.login() 125 | r, w = await pair.client.get_passive_connection() 126 | host, port, *_ = w.transport.get_extra_info("peername") 127 | assert port == unused_tcp_port 128 | assert pair.server.available_data_ports.qsize() == 0 129 | 130 | 131 | @pytest.mark.asyncio 132 | async def test_pasv_connection_pasv_forced_response_address(pair_factory, Server): 133 | def ipv4_used(): 134 | try: 135 | ipaddress.IPv4Address(pair.host) 136 | return True 137 | except ValueError: 138 | return False 139 | 140 | # using TEST-NET-1 address 141 | ipv4_address = "192.0.2.1" 142 | async with pair_factory( 143 | server=Server(ipv4_pasv_forced_response_address=ipv4_address), 144 | ) as pair: 145 | assert pair.server.ipv4_pasv_forced_response_address == ipv4_address 146 | 147 | if ipv4_used(): 148 | # The connection should fail here because the server starts to listen for 149 | # the passive connections on the host (IPv4 address) that is used 150 | # by the control channel. In reality, if the server is behind NAT, 151 | # the server is reached with the defined external IPv4 address, 152 | # i.e. we can check that the connection to 153 | # pair.server.ipv4_pasv_forced_response_address failed to know that 154 | # the server returned correct external IP 155 | # ... 156 | # but we can't use check like this: 157 | # with pytest.raises(OSError): 158 | # await pair.client.get_passive_connection(commands=["pasv"]) 159 | # because there is no such ipv4 which will be non-routable, so 160 | # we can only check `PASV` response 161 | ip, _ = await pair.client._do_pasv() 162 | assert ip == ipv4_address 163 | 164 | # With epsv the connection should open as that does not use the 165 | # external IPv4 address but just tells the client the port to connect 166 | # to 167 | await pair.client.get_passive_connection(commands=["epsv"]) 168 | 169 | 170 | @pytest.mark.parametrize("method", ["epsv", "pasv"]) 171 | @pytest.mark.asyncio 172 | async def test_pasv_connection_no_free_port( 173 | pair_factory, 174 | Server, 175 | expect_codes_in_exception, 176 | method, 177 | ): 178 | s = Server(data_ports=[]) 179 | async with pair_factory(None, s, do_quit=False, host="127.0.0.1") as pair: 180 | assert pair.server.available_data_ports.qsize() == 0 181 | with expect_codes_in_exception("421"): 182 | await pair.client.get_passive_connection(commands=[method]) 183 | 184 | 185 | @pytest.mark.asyncio 186 | async def test_pasv_connection_busy_port( 187 | pair_factory, 188 | Server, 189 | unused_tcp_port_factory, 190 | ): 191 | ports = [unused_tcp_port_factory(), unused_tcp_port_factory()] 192 | async with pair_factory(None, Server(data_ports=ports)) as pair: 193 | conflicting_server = await asyncio.start_server( 194 | lambda r, w: w.close(), 195 | host=pair.server.server_host, 196 | port=ports[0], 197 | ) 198 | r, w = await pair.client.get_passive_connection() 199 | host, port, *_ = w.transport.get_extra_info("peername") 200 | assert port == ports[1] 201 | assert pair.server.available_data_ports.qsize() == 1 202 | conflicting_server.close() 203 | await conflicting_server.wait_closed() 204 | 205 | 206 | @pytest.mark.asyncio 207 | async def test_pasv_connection_busy_port2( 208 | pair_factory, 209 | Server, 210 | unused_tcp_port_factory, 211 | expect_codes_in_exception, 212 | ): 213 | ports = [unused_tcp_port_factory()] 214 | s = Server(data_ports=ports) 215 | async with pair_factory(None, s, do_quit=False) as pair: 216 | conflicting_server = await asyncio.start_server( 217 | lambda r, w: w.close(), 218 | host=pair.server.server_host, 219 | port=ports[0], 220 | ) 221 | with expect_codes_in_exception("421"): 222 | await pair.client.get_passive_connection() 223 | conflicting_server.close() 224 | await conflicting_server.wait_closed() 225 | 226 | 227 | @pytest.mark.asyncio 228 | async def test_server_shutdown(pair_factory): 229 | async with pair_factory(do_quit=False) as pair: 230 | await pair.client.list() 231 | await pair.server.close() 232 | with pytest.raises(ConnectionResetError): 233 | await pair.client.list() 234 | 235 | 236 | @pytest.mark.asyncio 237 | async def test_client_session_context_manager(pair_factory): 238 | async with pair_factory(connected=False) as pair: 239 | async with aioftp.Client.context(*pair.server.address) as client: 240 | await client.list() 241 | 242 | 243 | @pytest.mark.asyncio 244 | async def test_long_login_sequence_fail( 245 | pair_factory, 246 | expect_codes_in_exception, 247 | ): 248 | class CustomServer(aioftp.Server): 249 | def __init__(self, *args, **kwargs): 250 | super().__init__(*args, **kwargs) 251 | self.commands_mapping["acct"] = self.acct 252 | 253 | async def user(self, connection, rest): 254 | connection.response("331") 255 | return True 256 | 257 | async def pass_(self, connection, rest): 258 | connection.response("332") 259 | return True 260 | 261 | async def acct(self, connection, rest): 262 | connection.response("333") 263 | return True 264 | 265 | factory = pair_factory( 266 | logged=False, 267 | server_factory=CustomServer, 268 | do_quit=False, 269 | ) 270 | async with factory as pair: 271 | with expect_codes_in_exception("333"): 272 | await pair.client.login() 273 | 274 | 275 | @pytest.mark.asyncio 276 | async def test_bad_sublines_seq(pair_factory, expect_codes_in_exception): 277 | class CustomServer(aioftp.Server): 278 | async def write_response(self, stream, code, lines="", list=False): 279 | import functools 280 | 281 | lines = aioftp.wrap_with_container(lines) 282 | write = functools.partial(self.write_line, stream) 283 | *body, tail = lines 284 | for line in body: 285 | await write(code + "-" + line) 286 | await write(str(int(code) + 1) + "-" + tail) 287 | await write(code + " " + tail) 288 | 289 | factory = pair_factory(connected=False, server_factory=CustomServer) 290 | async with factory as pair: 291 | with expect_codes_in_exception("220"): 292 | await pair.client.connect( 293 | pair.server.server_host, 294 | pair.server.server_port, 295 | ) 296 | await pair.client.login() 297 | -------------------------------------------------------------------------------- /tests/test_corner_cases.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | import aioftp 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_server_side_exception(pair_factory): 10 | class CustomServer(aioftp.Server): 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self.commands_mapping["custom"] = self.custom 14 | 15 | async def custom(*args, **kwargs): 16 | raise RuntimeError("Test error") 17 | 18 | factory = pair_factory(server_factory=CustomServer, do_quit=False) 19 | async with factory as pair: 20 | with pytest.raises(ConnectionResetError): 21 | await pair.client.command("custom", "200") 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_bad_type_value(pair_factory, expect_codes_in_exception): 26 | async with pair_factory() as pair: 27 | with expect_codes_in_exception("502"): 28 | await pair.client.command("type FOO", "200") 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_pbsz(pair_factory): 33 | async with pair_factory() as pair: 34 | await pair.client.command("pbsz", "200") 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_prot(pair_factory, expect_codes_in_exception): 39 | async with pair_factory() as pair: 40 | await pair.client.command("prot P", "200") 41 | with expect_codes_in_exception("502"): 42 | await pair.client.command("prot foo", "200") 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_server_ipv6_pasv(pair_factory, expect_codes_in_exception): 47 | async with pair_factory(host="::1", do_quit=False) as pair: 48 | with expect_codes_in_exception("503"): 49 | await pair.client.get_passive_connection(commands=["pasv"]) 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_epsv_extra_arg(pair_factory, expect_codes_in_exception): 54 | async with pair_factory(do_quit=False) as pair: 55 | with expect_codes_in_exception("522"): 56 | await pair.client.command("epsv foo", "229") 57 | 58 | 59 | @pytest.mark.asyncio 60 | async def test_bad_server_path_io( 61 | pair_factory, 62 | Server, 63 | expect_codes_in_exception, 64 | ): 65 | class BadPathIO(aioftp.MemoryPathIO): 66 | async def is_file(*a, **kw): 67 | return False 68 | 69 | async def is_dir(*a, **kw): 70 | return False 71 | 72 | s = Server(path_io_factory=BadPathIO) 73 | async with pair_factory(None, s) as pair: 74 | pio = pair.server.path_io_factory() 75 | async with pio.open(Path("/foo"), "wb"): 76 | pass 77 | await pair.client.list() 78 | -------------------------------------------------------------------------------- /tests/test_current_directory.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pytest 4 | 5 | import aioftp 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_current_directory_simple(pair_factory): 10 | async with pair_factory() as pair: 11 | cwd = await pair.client.get_current_directory() 12 | assert cwd == pathlib.PurePosixPath("/") 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_current_directory_not_default(pair_factory, Server): 17 | s = Server([aioftp.User(home_path="/home")]) 18 | async with pair_factory(None, s) as pair: 19 | cwd = await pair.client.get_current_directory() 20 | assert cwd == pathlib.PurePosixPath("/home") 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_mlsd(pair_factory): 25 | async with pair_factory() as pair: 26 | await pair.make_server_files("test.txt") 27 | (path, stat), *_ = files = await pair.client.list() 28 | assert len(files) == 1 29 | assert path == pathlib.PurePosixPath("test.txt") 30 | assert stat["type"] == "file" 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_resolving_double_dots(pair_factory): 35 | async with pair_factory() as pair: 36 | await pair.make_server_files("test.txt") 37 | 38 | async def f(): 39 | cwd = await pair.client.get_current_directory() 40 | assert cwd == pathlib.PurePosixPath("/") 41 | (path, stat), *_ = files = await pair.client.list() 42 | assert len(files) == 1 43 | assert path == pathlib.PurePosixPath("test.txt") 44 | assert stat["type"] == "file" 45 | 46 | await f() 47 | await pair.client.change_directory("../../../") 48 | await f() 49 | await pair.client.change_directory("/a/../b/..") 50 | await f() 51 | -------------------------------------------------------------------------------- /tests/test_directory_actions.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pytest 4 | 5 | import aioftp 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_create_and_remove_directory(pair_factory): 10 | async with pair_factory() as pair: 11 | await pair.client.make_directory("bar") 12 | (path, stat), *_ = files = await pair.client.list() 13 | assert len(files) == 1 14 | assert path == pathlib.PurePosixPath("bar") 15 | assert stat["type"] == "dir" 16 | 17 | await pair.client.remove_directory("bar") 18 | assert await pair.server_paths_exists("bar") is False 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_create_and_remove_directory_long(pair_factory): 23 | async with pair_factory() as pair: 24 | await pair.client.make_directory("bar/baz") 25 | assert await pair.server_paths_exists("bar", "bar/baz") 26 | await pair.client.remove_directory("bar/baz") 27 | await pair.client.remove_directory("bar") 28 | assert await pair.server_paths_exists("bar") is False 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_create_directory_long_no_parents(pair_factory): 33 | async with pair_factory() as pair: 34 | await pair.client.make_directory("bar/baz", parents=False) 35 | await pair.client.remove_directory("bar/baz") 36 | await pair.client.remove_directory("bar") 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_change_directory(pair_factory): 41 | async with pair_factory() as pair: 42 | await pair.client.make_directory("bar") 43 | await pair.client.change_directory("bar") 44 | cwd = await pair.client.get_current_directory() 45 | assert cwd == pathlib.PurePosixPath("/bar") 46 | await pair.client.change_directory() 47 | cwd = await pair.client.get_current_directory() 48 | assert cwd == pathlib.PurePosixPath("/") 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_change_directory_not_exist( 53 | pair_factory, 54 | expect_codes_in_exception, 55 | ): 56 | async with pair_factory() as pair: 57 | with expect_codes_in_exception("550"): 58 | await pair.client.change_directory("bar") 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_rename_empty_directory(pair_factory): 63 | async with pair_factory() as pair: 64 | await pair.client.make_directory("bar") 65 | assert await pair.server_paths_exists("bar") 66 | assert await pair.server_paths_exists("baz") is False 67 | await pair.client.rename("bar", "baz") 68 | assert await pair.server_paths_exists("bar") is False 69 | assert await pair.server_paths_exists("baz") 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_rename_non_empty_directory(pair_factory): 74 | async with pair_factory() as pair: 75 | await pair.make_server_files("bar/foo.txt") 76 | assert await pair.server_paths_exists("bar/foo.txt", "bar") 77 | await pair.client.make_directory("hurr") 78 | await pair.client.rename("bar", "hurr/baz") 79 | assert await pair.server_paths_exists("hurr/baz/foo.txt") 80 | assert await pair.server_paths_exists("bar") is False 81 | 82 | 83 | class FakeErrorPathIO(aioftp.MemoryPathIO): 84 | def list(self, path): 85 | class Lister(aioftp.AbstractAsyncLister): 86 | @aioftp.pathio.universal_exception 87 | async def __anext__(self): 88 | raise Exception("KERNEL PANIC") 89 | 90 | return Lister(timeout=self.timeout) 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_exception_in_list( 95 | pair_factory, 96 | Server, 97 | expect_codes_in_exception, 98 | ): 99 | s = Server(path_io_factory=FakeErrorPathIO) 100 | async with pair_factory(None, s) as pair: 101 | with expect_codes_in_exception("451"): 102 | await pair.client.list() 103 | 104 | 105 | @pytest.mark.asyncio 106 | async def test_list_recursive(pair_factory): 107 | async with pair_factory() as pair: 108 | await pair.make_server_files("foo/bar", "foo/baz/baz") 109 | files = await pair.client.list(recursive=True) 110 | assert len(files) == 4 111 | -------------------------------------------------------------------------------- /tests/test_extra.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aioftp 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_stream_iter_by_line(pair_factory): 8 | async with pair_factory() as pair: 9 | await pair.client.make_directory("bar") 10 | lines = [] 11 | async with pair.client.get_stream("list") as stream: 12 | async for line in stream.iter_by_line(): 13 | lines.append(line) 14 | assert len(lines) == 1 15 | assert b"bar" in lines[0] 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_stream_close_without_finish(pair_factory): 20 | class CustomException(Exception): 21 | pass 22 | 23 | def fake_finish(*a, **kw): 24 | raise Exception("Finished called") 25 | 26 | async with pair_factory() as pair: 27 | with pytest.raises(CustomException): 28 | async with pair.client.get_stream(): 29 | raise CustomException() 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_no_server(unused_tcp_port): 34 | with pytest.raises(OSError): 35 | async with aioftp.Client.context("127.0.0.1", unused_tcp_port): 36 | pass 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_syst_command(pair_factory): 41 | async with pair_factory() as pair: 42 | code, info = await pair.client.command("syst", "215") 43 | assert info == [" UNIX Type: L8"] 44 | -------------------------------------------------------------------------------- /tests/test_file.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import math 3 | from pathlib import PurePosixPath 4 | 5 | import pytest 6 | 7 | import aioftp 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_remove_single_file(pair_factory): 12 | async with pair_factory() as pair: 13 | await pair.make_server_files("foo.txt") 14 | assert await pair.server_paths_exists("foo.txt") 15 | await pair.client.remove_file("foo.txt") 16 | assert await pair.server_paths_exists("foo.txt") is False 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_recursive_remove(pair_factory): 21 | async with pair_factory() as pair: 22 | paths = ["foo/bar.txt", "foo/baz.txt", "foo/bar_dir/foo.baz"] 23 | await pair.make_server_files(*paths) 24 | await pair.client.remove("foo") 25 | assert await pair.server_paths_exists(*paths) is False 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_mlsd_file(pair_factory): 30 | async with pair_factory() as pair: 31 | await pair.make_server_files("foo/bar.txt") 32 | result = await pair.client.list("foo/bar.txt") 33 | assert len(result) == 0 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_file_download(pair_factory): 38 | async with pair_factory() as pair: 39 | await pair.make_server_files("foo", size=1, atom=b"foobar") 40 | async with pair.client.download_stream("foo") as stream: 41 | data = await stream.read() 42 | assert data == b"foobar" 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_file_download_exactly(pair_factory): 47 | async with pair_factory() as pair: 48 | await pair.make_server_files("foo", size=1, atom=b"foobar") 49 | async with pair.client.download_stream("foo") as stream: 50 | data1 = await stream.readexactly(3) 51 | data2 = await stream.readexactly(3) 52 | assert (data1, data2) == (b"foo", b"bar") 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_file_download_enhanced_passive(pair_factory): 57 | async with pair_factory() as pair: 58 | pair.client._passive_commands = ["epsv"] 59 | await pair.make_server_files("foo", size=1, atom=b"foobar") 60 | async with pair.client.download_stream("foo") as stream: 61 | data = await stream.read() 62 | assert data == b"foobar" 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_file_upload(pair_factory): 67 | async with pair_factory() as pair: 68 | async with pair.client.upload_stream("foo") as stream: 69 | await stream.write(b"foobar") 70 | async with pair.client.download_stream("foo") as stream: 71 | data = await stream.read() 72 | assert data == b"foobar" 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_file_append(pair_factory): 77 | async with pair_factory() as pair: 78 | await pair.make_server_files("foo", size=1, atom=b"foobar") 79 | async with pair.client.append_stream("foo") as stream: 80 | await stream.write(b"foobar") 81 | async with pair.client.download_stream("foo") as stream: 82 | data = await stream.read() 83 | assert data == b"foobar" * 2 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_upload_folder(pair_factory): 88 | async with pair_factory() as pair: 89 | paths = ["foo/bar", "foo/baz"] 90 | await pair.make_client_files(*paths) 91 | assert await pair.server_paths_exists(*paths) is False 92 | await pair.client.upload("foo") 93 | assert await pair.server_paths_exists(*paths) 94 | 95 | 96 | @pytest.mark.asyncio 97 | async def test_upload_folder_into(pair_factory): 98 | async with pair_factory() as pair: 99 | paths = ["foo/bar", "foo/baz"] 100 | await pair.make_client_files(*paths) 101 | assert await pair.server_paths_exists("bar", "baz") is False 102 | await pair.client.upload("foo", write_into=True) 103 | assert await pair.server_paths_exists("bar", "baz") 104 | 105 | 106 | @pytest.mark.asyncio 107 | async def test_upload_folder_into_another(pair_factory): 108 | async with pair_factory() as pair: 109 | paths = ["foo/bar", "foo/baz"] 110 | await pair.make_client_files(*paths) 111 | assert await pair.server_paths_exists("bar/bar", "bar/baz") is False 112 | await pair.client.upload("foo", "bar", write_into=True) 113 | assert await pair.server_paths_exists("bar/bar", "bar/baz") 114 | 115 | 116 | @pytest.mark.asyncio 117 | async def test_download_folder(pair_factory): 118 | async with pair_factory() as pair: 119 | paths = ["foo/bar", "foo/baz"] 120 | await pair.make_server_files(*paths) 121 | assert await pair.client_paths_exists(*paths) is False 122 | await pair.client.download("foo") 123 | assert await pair.client_paths_exists(*paths) 124 | 125 | 126 | @pytest.mark.asyncio 127 | async def test_download_folder_into(pair_factory): 128 | async with pair_factory() as pair: 129 | paths = ["foo/bar", "foo/baz"] 130 | await pair.make_server_files(*paths) 131 | assert await pair.client_paths_exists("bar", "baz") is False 132 | await pair.client.download("foo", write_into=True) 133 | assert await pair.client_paths_exists("bar", "baz") 134 | 135 | 136 | @pytest.mark.asyncio 137 | async def test_download_folder_into_another(pair_factory): 138 | async with pair_factory() as pair: 139 | paths = ["foo/bar", "foo/baz"] 140 | await pair.make_server_files(*paths) 141 | assert await pair.client_paths_exists("bar/bar", "bar/baz") is False 142 | await pair.client.download("foo", "bar", write_into=True) 143 | assert await pair.client_paths_exists("bar/bar", "bar/baz") 144 | 145 | 146 | @pytest.mark.asyncio 147 | async def test_upload_file_over(pair_factory): 148 | async with pair_factory() as pair: 149 | await pair.make_client_files("foo", size=1, atom=b"client") 150 | await pair.make_server_files("foo", size=1, atom=b"server") 151 | async with pair.client.download_stream("foo") as stream: 152 | assert await stream.read() == b"server" 153 | await pair.client.upload("foo") 154 | async with pair.client.download_stream("foo") as stream: 155 | assert await stream.read() == b"client" 156 | 157 | 158 | @pytest.mark.asyncio 159 | async def test_download_file_over(pair_factory): 160 | async with pair_factory() as pair: 161 | await pair.make_client_files("foo", size=1, atom=b"client") 162 | await pair.make_server_files("foo", size=1, atom=b"server") 163 | async with pair.client.path_io.open(PurePosixPath("foo")) as f: 164 | assert await f.read() == b"client" 165 | await pair.client.download("foo") 166 | async with pair.client.path_io.open(PurePosixPath("foo")) as f: 167 | assert await f.read() == b"server" 168 | 169 | 170 | @pytest.mark.asyncio 171 | async def test_upload_file_write_into(pair_factory): 172 | async with pair_factory() as pair: 173 | await pair.make_client_files("foo", size=1, atom=b"client") 174 | await pair.make_server_files("bar", size=1, atom=b"server") 175 | async with pair.client.download_stream("bar") as stream: 176 | assert await stream.read() == b"server" 177 | await pair.client.upload("foo", "bar", write_into=True) 178 | async with pair.client.download_stream("bar") as stream: 179 | assert await stream.read() == b"client" 180 | 181 | 182 | @pytest.mark.asyncio 183 | async def test_upload_tree(pair_factory): 184 | async with pair_factory() as pair: 185 | await pair.make_client_files("foo/bar/baz", size=1, atom=b"client") 186 | await pair.client.upload("foo", "bar", write_into=True) 187 | files = await pair.client.list(recursive=True) 188 | assert len(files) == 3 189 | 190 | 191 | @pytest.mark.asyncio 192 | async def test_download_file_write_into(pair_factory): 193 | async with pair_factory() as pair: 194 | await pair.make_client_files("foo", size=1, atom=b"client") 195 | await pair.make_server_files("bar", size=1, atom=b"server") 196 | async with pair.client.path_io.open(PurePosixPath("foo")) as f: 197 | assert await f.read() == b"client" 198 | await pair.client.download("bar", "foo", write_into=True) 199 | async with pair.client.path_io.open(PurePosixPath("foo")) as f: 200 | assert await f.read() == b"server" 201 | 202 | 203 | @pytest.mark.asyncio 204 | async def test_upload_file_os_error( 205 | pair_factory, 206 | Server, 207 | expect_codes_in_exception, 208 | ): 209 | class OsErrorPathIO(aioftp.MemoryPathIO): 210 | @aioftp.pathio.universal_exception 211 | async def write(self, fout, data): 212 | raise OSError("test os error") 213 | 214 | s = Server(path_io_factory=OsErrorPathIO) 215 | async with pair_factory(None, s) as pair: 216 | with expect_codes_in_exception("451"): 217 | async with pair.client.upload_stream("foo") as stream: 218 | await stream.write(b"foobar") 219 | 220 | 221 | @pytest.mark.asyncio 222 | async def test_upload_path_unreachable( 223 | pair_factory, 224 | expect_codes_in_exception, 225 | ): 226 | async with pair_factory() as pair: 227 | with expect_codes_in_exception("550"): 228 | async with pair.client.upload_stream("foo/bar/foo") as stream: 229 | await stream.write(b"foobar") 230 | 231 | 232 | @pytest.mark.asyncio 233 | async def test_stat_when_no_mlst(pair_factory): 234 | async with pair_factory() as pair: 235 | pair.server.commands_mapping.pop("mlst") 236 | await pair.make_server_files("foo") 237 | info = await pair.client.stat("foo") 238 | assert info["type"] == "file" 239 | 240 | 241 | @pytest.mark.asyncio 242 | async def test_stat_mlst(pair_factory): 243 | async with pair_factory() as pair: 244 | now = dt.datetime.utcnow() 245 | await pair.make_server_files("foo") 246 | info = await pair.client.stat("foo") 247 | assert info["type"] == "file" 248 | for fact in ("modify", "create"): 249 | received = dt.datetime.strptime(info[fact], "%Y%m%d%H%M%S") 250 | assert math.isclose( 251 | now.timestamp(), 252 | received.timestamp(), 253 | abs_tol=10, 254 | ) 255 | -------------------------------------------------------------------------------- /tests/test_list_fallback.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import pathlib 3 | import textwrap 4 | 5 | import pytest 6 | 7 | import aioftp 8 | 9 | 10 | async def not_implemented(connection, rest): 11 | connection.response("502", ":P") 12 | return True 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_client_fallback_to_list_at_list(pair_factory): 17 | async with pair_factory() as pair: 18 | pair.server.commands_mapping["mlst"] = not_implemented 19 | pair.server.commands_mapping["mlsd"] = not_implemented 20 | await pair.make_server_files("bar/foo") 21 | (path, stat), *_ = files = await pair.client.list() 22 | assert len(files) == 1 23 | assert path == pathlib.PurePosixPath("bar") 24 | assert stat["type"] == "dir" 25 | (path, stat), *_ = files = await pair.client.list("bar") 26 | assert len(files) == 1 27 | assert path == pathlib.PurePosixPath("bar/foo") 28 | assert stat["type"] == "file" 29 | result = await pair.client.list("bar/foo") 30 | assert len(result) == 0 31 | 32 | 33 | async def implemented_badly(connection, rest): 34 | assert False, "should not be called" 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_client_list_override(pair_factory): 39 | async with pair_factory() as pair: 40 | pair.server.commands_mapping["mlsd"] = implemented_badly 41 | await pair.client.make_directory("bar") 42 | (path, stat), *_ = files = await pair.client.list(raw_command="LIST") 43 | assert len(files) == 1 44 | assert path == pathlib.PurePosixPath("bar") 45 | assert stat["type"] == "dir" 46 | 47 | 48 | @pytest.mark.asyncio 49 | async def test_client_list_override_invalid_raw_command(pair_factory): 50 | async with pair_factory() as pair: 51 | with pytest.raises(ValueError): 52 | await pair.client.list(raw_command="FOO") 53 | 54 | 55 | def test_client_list_windows(): 56 | test_str = textwrap.dedent( 57 | """\ 58 | 11/4/2018 9:09 PM . 59 | 8/10/2018 1:02 PM .. 60 | 9/23/2018 2:16 PM bin 61 | 10/16/2018 10:25 PM Desktop 62 | 11/4/2018 3:31 PM dow 63 | 10/16/2018 8:21 PM Downloads 64 | 10/14/2018 5:34 PM msc 65 | 9/9/2018 9:32 AM opt 66 | 10/3/2018 2:58 PM 34,359,738,368 win10.img 67 | 6/30/2018 8:36 AM 3,939,237,888 win10.iso 68 | 7/26/2018 1:11 PM 189 win10.sh 69 | 10/29/2018 11:46 AM 34,359,738,368 win7.img 70 | 6/30/2018 8:35 AM 3,319,791,616 win7.iso 71 | 10/29/2018 10:55 AM 219 win7.sh 72 | 6 files 75,978,506,648 bytes 73 | 3 directories 22,198,362,112 bytes free 74 | """, 75 | ) 76 | test_str = test_str.strip().split("\n") 77 | entities = {} 78 | parse = aioftp.Client(encoding="utf-8").parse_list_line_windows 79 | for x in test_str: 80 | with contextlib.suppress(ValueError): 81 | path, stat = parse(x.encode("utf-8")) 82 | entities[path] = stat 83 | dirs = ["bin", "Desktop", "dow", "Downloads", "msc", "opt"] 84 | files = [ 85 | "win10.img", 86 | "win10.iso", 87 | "win10.sh", 88 | "win7.img", 89 | "win7.iso", 90 | "win7.sh", 91 | ] 92 | assert len(entities) == len(dirs + files) 93 | for d in dirs: 94 | p = pathlib.PurePosixPath(d) 95 | assert p in entities 96 | assert entities[p]["type"] == "dir" 97 | for f in files: 98 | p = pathlib.PurePosixPath(f) 99 | assert p in entities 100 | assert entities[p]["type"] == "file" 101 | with pytest.raises(ValueError): 102 | parse(b" 10/3/2018 2:58 PM 34,35xxx38,368 win10.img") 103 | 104 | 105 | @pytest.mark.asyncio 106 | async def test_client_list_override_with_custom(pair_factory, Client): 107 | meta = {"type": "file", "works": True} 108 | 109 | def parser(b): 110 | import pickle 111 | 112 | return pickle.loads(bytes.fromhex(b.decode().rstrip("\r\n"))) 113 | 114 | async def builder(_, path): 115 | import pickle 116 | 117 | return pickle.dumps((path, meta)).hex() 118 | 119 | async with pair_factory(Client(parse_list_line_custom=parser)) as pair: 120 | pair.server.commands_mapping["mlst"] = not_implemented 121 | pair.server.commands_mapping["mlsd"] = not_implemented 122 | pair.server.build_list_string = builder 123 | await pair.client.make_directory("bar") 124 | (path, stat), *_ = files = await pair.client.list() 125 | assert len(files) == 1 126 | assert path == pathlib.PurePosixPath("bar") 127 | assert stat == meta 128 | 129 | 130 | @pytest.mark.asyncio 131 | async def test_client_list_override_with_custom_last(pair_factory, Client): 132 | meta = {"type": "file", "works": True} 133 | 134 | def parser(b): 135 | import pickle 136 | 137 | return pickle.loads(bytes.fromhex(b.decode().rstrip("\r\n"))) 138 | 139 | async def builder(_, path): 140 | import pickle 141 | 142 | return pickle.dumps((path, meta)).hex() 143 | 144 | client = Client( 145 | parse_list_line_custom=parser, 146 | parse_list_line_custom_first=False, 147 | ) 148 | async with pair_factory(client) as pair: 149 | pair.server.commands_mapping["mlst"] = not_implemented 150 | pair.server.commands_mapping["mlsd"] = not_implemented 151 | pair.server.build_list_string = builder 152 | await pair.client.make_directory("bar") 153 | (path, stat), *_ = files = await pair.client.list() 154 | assert len(files) == 1 155 | assert path == pathlib.PurePosixPath("bar") 156 | assert stat == meta 157 | -------------------------------------------------------------------------------- /tests/test_login.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aioftp 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_client_list_override(pair_factory, expect_codes_in_exception): 8 | async with pair_factory(logged=False, do_quit=False) as pair: 9 | with expect_codes_in_exception("503"): 10 | await pair.client.get_current_directory() 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_anonymous_login(pair_factory): 15 | async with pair_factory(): 16 | pass 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_login_with_login_data(pair_factory): 21 | async with pair_factory(logged=False) as pair: 22 | await pair.client.login("foo", "bar") 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_login_with_login_and_no_password(pair_factory, Server): 27 | s = Server([aioftp.User("foo")]) 28 | async with pair_factory(None, s, logged=False) as pair: 29 | await pair.client.login("foo") 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_login_with_login_and_password(pair_factory, Server): 34 | s = Server([aioftp.User("foo", "bar")]) 35 | async with pair_factory(None, s, logged=False) as pair: 36 | await pair.client.login("foo", "bar") 37 | 38 | 39 | @pytest.mark.asyncio 40 | async def test_login_with_login_and_password_no_such_user( 41 | pair_factory, 42 | Server, 43 | expect_codes_in_exception, 44 | ): 45 | s = Server([aioftp.User("foo", "bar")]) 46 | async with pair_factory(None, s, logged=False) as pair: 47 | with expect_codes_in_exception("530"): 48 | await pair.client.login("fo", "bar") 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_login_with_login_and_password_bad_password( 53 | pair_factory, 54 | Server, 55 | expect_codes_in_exception, 56 | ): 57 | s = Server([aioftp.User("foo", "bar")]) 58 | async with pair_factory(None, s, logged=False) as pair: 59 | with expect_codes_in_exception("530"): 60 | await pair.client.login("foo", "baz") 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_pass_after_login( 65 | pair_factory, 66 | Server, 67 | expect_codes_in_exception, 68 | ): 69 | s = Server([aioftp.User("foo", "bar")]) 70 | async with pair_factory(None, s, logged=False) as pair: 71 | await pair.client.login("foo", "bar") 72 | with expect_codes_in_exception("503"): 73 | await pair.client.command("PASS baz", ("230", "33x")) 74 | -------------------------------------------------------------------------------- /tests/test_maximum_connections.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import pytest 4 | 5 | import aioftp 6 | 7 | 8 | @pytest.mark.asyncio 9 | async def test_multiply_connections_no_limits(pair_factory): 10 | Client = functools.partial( 11 | aioftp.Client, 12 | path_io_factory=aioftp.MemoryPathIO, 13 | ) 14 | async with pair_factory() as pair: 15 | s = pair.server 16 | clients = [Client() for _ in range(4)] 17 | for c in clients: 18 | await c.connect(s.server_host, s.server_port) 19 | await c.login() 20 | for c in clients: 21 | await c.quit() 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_multiply_connections_limited_error( 26 | pair_factory, 27 | Server, 28 | expect_codes_in_exception, 29 | ): 30 | Client = functools.partial( 31 | aioftp.Client, 32 | path_io_factory=aioftp.MemoryPathIO, 33 | ) 34 | s = Server(maximum_connections=4) 35 | async with pair_factory(None, s) as pair: 36 | s = pair.server 37 | clients = [Client() for _ in range(4)] 38 | for c in clients[:-1]: 39 | await c.connect(s.server_host, s.server_port) 40 | await c.login() 41 | with expect_codes_in_exception("421"): 42 | await clients[-1].connect(s.server_host, s.server_port) 43 | for c in clients[:-1]: 44 | await c.quit() 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_multiply_user_commands(pair_factory, Server): 49 | s = Server(maximum_connections=1) 50 | async with pair_factory(None, s) as pair: 51 | for _ in range(10): 52 | await pair.client.login() 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_multiply_connections_with_user_limited_error( 57 | pair_factory, 58 | Server, 59 | expect_codes_in_exception, 60 | ): 61 | Client = functools.partial( 62 | aioftp.Client, 63 | path_io_factory=aioftp.MemoryPathIO, 64 | ) 65 | s = Server([aioftp.User("foo", maximum_connections=4)]) 66 | async with pair_factory(None, s, connected=False) as pair: 67 | s = pair.server 68 | clients = [Client() for _ in range(5)] 69 | for c in clients[:-1]: 70 | await c.connect(s.server_host, s.server_port) 71 | await c.login("foo") 72 | await clients[-1].connect(s.server_host, s.server_port) 73 | with expect_codes_in_exception("530"): 74 | await clients[-1].login("foo") 75 | for c in clients[:-1]: 76 | await c.quit() 77 | 78 | 79 | @pytest.mark.asyncio 80 | async def test_multiply_connections_relogin_balanced( 81 | pair_factory, 82 | Server, 83 | expect_codes_in_exception, 84 | ): 85 | Client = functools.partial( 86 | aioftp.Client, 87 | path_io_factory=aioftp.MemoryPathIO, 88 | ) 89 | s = Server(maximum_connections=4) 90 | async with pair_factory(None, s, connected=False) as pair: 91 | s = pair.server 92 | clients = [Client() for _ in range(5)] 93 | for c in clients[:-1]: 94 | await c.connect(s.server_host, s.server_port) 95 | await c.login() 96 | await clients[0].quit() 97 | await clients[-1].connect(s.server_host, s.server_port) 98 | await clients[-1].login() 99 | for c in clients[1:]: 100 | await c.quit() 101 | -------------------------------------------------------------------------------- /tests/test_passive.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | async def not_implemented(connection, rest): 5 | connection.response("502", ":P") 6 | return True 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_client_fallback_to_pasv_at_list(pair_factory): 11 | async with pair_factory(host="127.0.0.1") as pair: 12 | pair.server.epsv = not_implemented 13 | await pair.client.list() 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_client_fail_fallback_to_pasv_at_list( 18 | pair_factory, 19 | expect_codes_in_exception, 20 | ): 21 | async with pair_factory(host="127.0.0.1") as pair: 22 | pair.server.commands_mapping["epsv"] = not_implemented 23 | with expect_codes_in_exception("502"): 24 | await pair.client.get_passive_connection(commands=["epsv"]) 25 | with expect_codes_in_exception("502"): 26 | pair.client._passive_commands = ["epsv"] 27 | await pair.client.get_passive_connection() 28 | 29 | 30 | @pytest.mark.asyncio 31 | async def test_client_only_passive_list(pair_factory): 32 | async with pair_factory(host="127.0.0.1") as pair: 33 | pair.client._passive_commands = ["pasv"] 34 | await pair.client.list() 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_client_only_enhanced_passive_list(pair_factory): 39 | async with pair_factory(host="127.0.0.1") as pair: 40 | pair.client._passive_commands = ["epsv"] 41 | await pair.client.list() 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_passive_no_choices(pair_factory): 46 | async with pair_factory() as pair: 47 | pair.client._passive_commands = [] 48 | with pytest.raises(ValueError): 49 | await pair.client.get_passive_connection(commands=[]) 50 | 51 | 52 | @pytest.mark.asyncio 53 | async def test_passive_bad_choices(pair_factory): 54 | async with pair_factory() as pair: 55 | pair.server.epsv = not_implemented 56 | with pytest.raises(ValueError): 57 | await pair.client.get_passive_connection(commands=["FOO"]) 58 | 59 | 60 | @pytest.mark.parametrize("method", [("pasv", "227"), ("epsv", "229")]) 61 | @pytest.mark.asyncio 62 | async def test_passive_multicall(pair_factory, method): 63 | async with pair_factory(host="127.0.0.1") as pair: 64 | code, info = await pair.client.command(*method) 65 | assert "created" in info[0] 66 | code, info = await pair.client.command(*method) 67 | assert "exists" in info[0] 68 | 69 | 70 | @pytest.mark.parametrize("method", ["pasv", "epsv"]) 71 | @pytest.mark.asyncio 72 | async def test_passive_closed_on_recall(pair_factory, method): 73 | async with pair_factory(host="127.0.0.1") as pair: 74 | r, w = await pair.client.get_passive_connection(commands=[method]) 75 | nr, nw = await pair.client.get_passive_connection(commands=[method]) 76 | with pytest.raises((ConnectionResetError, BrokenPipeError)): 77 | while True: 78 | w.write(b"-") 79 | await w.drain() 80 | -------------------------------------------------------------------------------- /tests/test_pathio.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | import pytest 4 | 5 | import aioftp 6 | 7 | 8 | @contextlib.contextmanager 9 | def universal_exception_reason(*exc): 10 | try: 11 | yield 12 | except aioftp.PathIOError as e: 13 | type, instance, traceback = e.reason 14 | m = f"Expect one of {exc}, got {instance}" 15 | assert isinstance(instance, exc), m 16 | else: 17 | raise Exception(f"No excepton. Expect one of {exc}") 18 | 19 | 20 | def test_has_state(path_io): 21 | assert hasattr(path_io, "state") 22 | path_io.state 23 | 24 | 25 | @pytest.mark.asyncio 26 | async def test_exists(path_io, temp_dir): 27 | assert await path_io.exists(temp_dir) 28 | assert not await path_io.exists(temp_dir / "foo") 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_is_dir(path_io, temp_dir): 33 | assert await path_io.is_dir(temp_dir) 34 | p = temp_dir / "foo" 35 | async with path_io.open(p, mode="wb"): 36 | pass 37 | assert not await path_io.is_dir(p) 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_is_file(path_io, temp_dir): 42 | p = temp_dir / "foo" 43 | assert not await path_io.is_file(temp_dir) 44 | async with path_io.open(p, mode="wb"): 45 | pass 46 | assert await path_io.is_file(p) 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_mkdir(path_io, temp_dir): 51 | p = temp_dir / "foo" 52 | assert not await path_io.exists(p) 53 | await path_io.mkdir(p) 54 | assert await path_io.exists(p) 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_rmdir(path_io, temp_dir): 59 | p = temp_dir / "foo" 60 | assert not await path_io.exists(p) 61 | await path_io.mkdir(p) 62 | assert await path_io.exists(p) 63 | await path_io.rmdir(p) 64 | assert not await path_io.exists(p) 65 | 66 | 67 | @pytest.mark.asyncio 68 | async def test_ulink(path_io, temp_dir): 69 | p = temp_dir / "foo" 70 | assert not await path_io.exists(p) 71 | async with path_io.open(p, mode="wb"): 72 | pass 73 | assert await path_io.exists(p) 74 | await path_io.unlink(p) 75 | assert not await path_io.exists(p) 76 | 77 | 78 | @pytest.mark.asyncio 79 | async def test_list(path_io, temp_dir): 80 | d, f = temp_dir / "dir", temp_dir / "file" 81 | await path_io.mkdir(d) 82 | async with path_io.open(f, mode="wb"): 83 | pass 84 | paths = await path_io.list(temp_dir) 85 | assert len(paths) == 2 86 | assert set(paths) == {d, f} 87 | paths = set() 88 | async for p in path_io.list(temp_dir): 89 | paths.add(p) 90 | assert set(paths) == {d, f} 91 | 92 | 93 | @pytest.mark.asyncio 94 | async def test_stat(path_io, temp_dir): 95 | stat = await path_io.stat(temp_dir) 96 | for a in ["st_size", "st_mtime", "st_ctime", "st_nlink", "st_mode"]: 97 | assert hasattr(stat, a) 98 | 99 | 100 | @pytest.mark.asyncio 101 | async def test_open_context(path_io, temp_dir): 102 | p = temp_dir / "context" 103 | async with path_io.open(p, mode="wb") as f: 104 | await f.write(b"foo") 105 | async with path_io.open(p, mode="rb") as f: 106 | assert await f.read() == b"foo" 107 | async with path_io.open(p, mode="ab") as f: 108 | await f.write(b"bar") 109 | async with path_io.open(p, mode="rb") as f: 110 | assert await f.read() == b"foobar" 111 | async with path_io.open(p, mode="wb") as f: 112 | await f.write(b"foo") 113 | async with path_io.open(p, mode="rb") as f: 114 | assert await f.read() == b"foo" 115 | async with path_io.open(p, mode="r+b") as f: 116 | assert await f.read(1) == b"f" 117 | await f.write(b"un") 118 | async with path_io.open(p, mode="rb") as f: 119 | assert await f.read() == b"fun" 120 | 121 | 122 | @pytest.mark.asyncio 123 | async def test_open_plain(path_io, temp_dir): 124 | p = temp_dir / "plain" 125 | f = await path_io.open(p, mode="wb") 126 | await f.write(b"foo") 127 | await f.close() 128 | f = await path_io.open(p, mode="rb") 129 | assert await f.read() == b"foo" 130 | await f.close() 131 | f = await path_io.open(p, mode="ab") 132 | await f.write(b"bar") 133 | await f.close() 134 | f = await path_io.open(p, mode="rb") 135 | assert await f.read() == b"foobar" 136 | await f.close() 137 | f = await path_io.open(p, mode="wb") 138 | await f.write(b"foo") 139 | await f.close() 140 | f = await path_io.open(p, mode="rb") 141 | assert await f.read() == b"foo" 142 | await f.close() 143 | f = await path_io.open(p, mode="r+b") 144 | assert await f.read(1) == b"f" 145 | await f.write(b"un") 146 | await f.close() 147 | f = await path_io.open(p, mode="rb") 148 | assert await f.read() == b"fun" 149 | await f.close() 150 | 151 | 152 | @pytest.mark.asyncio 153 | async def test_file_methods(path_io, temp_dir): 154 | p = temp_dir / "foo" 155 | async with path_io.open(p, mode="wb") as f: 156 | await f.write(b"foo") 157 | with universal_exception_reason(ValueError): 158 | await path_io.seek(f, 0) 159 | await f.seek(0) 160 | await f.write(b"bar") 161 | async with path_io.open(p, mode="rb") as f: 162 | assert await f.read() == b"bar" 163 | 164 | 165 | @pytest.mark.asyncio 166 | async def test_rename(path_io, temp_dir): 167 | old = temp_dir / "foo" 168 | new = temp_dir / "bar" 169 | await path_io.mkdir(old) 170 | assert await path_io.exists(old) 171 | assert not await path_io.exists(new) 172 | await path_io.rename(old, new) 173 | assert not await path_io.exists(old) 174 | assert await path_io.exists(new) 175 | 176 | 177 | def test_repr_works(path_io, temp_dir): 178 | repr(path_io) 179 | 180 | 181 | @pytest.mark.asyncio 182 | async def test_path_over_file(path_io, temp_dir): 183 | f = temp_dir / "file" 184 | async with path_io.open(f, mode="wb"): 185 | pass 186 | assert not await path_io.exists(f / "dir") 187 | 188 | 189 | @pytest.mark.asyncio 190 | async def test_mkdir_over_file(path_io, temp_dir): 191 | f = temp_dir / "file" 192 | async with path_io.open(f, mode="wb"): 193 | pass 194 | with universal_exception_reason(FileExistsError): 195 | await path_io.mkdir(f) 196 | 197 | 198 | @pytest.mark.asyncio 199 | async def test_mkdir_no_parents(path_io, temp_dir): 200 | p = temp_dir / "foo" / "bar" 201 | with universal_exception_reason(FileNotFoundError): 202 | await path_io.mkdir(p) 203 | 204 | 205 | @pytest.mark.asyncio 206 | async def test_mkdir_parent_is_file(path_io, temp_dir): 207 | f = temp_dir / "foo" 208 | async with path_io.open(f, mode="wb"): 209 | pass 210 | with universal_exception_reason(NotADirectoryError): 211 | await path_io.mkdir(f / "bar") 212 | 213 | 214 | @pytest.mark.asyncio 215 | async def test_mkdir_parent_is_file_with_parents(path_io, temp_dir): 216 | f = temp_dir / "foo" 217 | async with path_io.open(f, mode="wb"): 218 | pass 219 | with universal_exception_reason(NotADirectoryError): 220 | await path_io.mkdir(f / "bar", parents=True) 221 | 222 | 223 | @pytest.mark.asyncio 224 | async def test_rmdir_not_exist(path_io, temp_dir): 225 | with universal_exception_reason(FileNotFoundError): 226 | await path_io.rmdir(temp_dir / "foo") 227 | 228 | 229 | @pytest.mark.asyncio 230 | async def test_rmdir_on_file(path_io, temp_dir): 231 | f = temp_dir / "foo" 232 | async with path_io.open(f, mode="wb"): 233 | pass 234 | with universal_exception_reason(NotADirectoryError): 235 | await path_io.rmdir(f) 236 | 237 | 238 | @pytest.mark.asyncio 239 | async def test_rmdir_not_empty(path_io, temp_dir): 240 | f = temp_dir / "foo" 241 | async with path_io.open(f, mode="wb"): 242 | pass 243 | with universal_exception_reason(OSError): 244 | await path_io.rmdir(temp_dir) 245 | 246 | 247 | @pytest.mark.asyncio 248 | async def test_unlink_not_exist(path_io, temp_dir): 249 | with universal_exception_reason(FileNotFoundError): 250 | await path_io.unlink(temp_dir / "foo") 251 | 252 | 253 | @pytest.mark.asyncio 254 | async def test_unlink_on_dir(path_io, temp_dir): 255 | with universal_exception_reason(IsADirectoryError, PermissionError): 256 | await path_io.unlink(temp_dir) 257 | 258 | 259 | @pytest.mark.asyncio 260 | async def test_list_not_exist(path_io, temp_dir): 261 | assert await path_io.list(temp_dir / "foo") == [] 262 | 263 | 264 | @pytest.mark.asyncio 265 | async def test_stat_not_exist(path_io, temp_dir): 266 | with universal_exception_reason(FileNotFoundError): 267 | await path_io.stat(temp_dir / "foo") 268 | 269 | 270 | @pytest.mark.asyncio 271 | async def test_open_read_not_exist(path_io, temp_dir): 272 | with universal_exception_reason(FileNotFoundError): 273 | await path_io.open(temp_dir / "foo", mode="rb") 274 | 275 | 276 | @pytest.mark.asyncio 277 | async def test_open_write_unreachable(path_io, temp_dir): 278 | with universal_exception_reason(FileNotFoundError): 279 | await path_io.open(temp_dir / "foo" / "bar", mode="wb") 280 | 281 | 282 | @pytest.mark.asyncio 283 | async def test_open_write_directory(path_io, temp_dir): 284 | with universal_exception_reason(IsADirectoryError): 285 | await path_io.open(temp_dir, mode="wb") 286 | 287 | 288 | @pytest.mark.asyncio 289 | async def test_open_bad_mode(path_io, temp_dir): 290 | with universal_exception_reason(ValueError): 291 | await path_io.open(temp_dir, mode="bad") 292 | 293 | 294 | @pytest.mark.asyncio 295 | async def test_rename_source_or_dest_parent_not_exist(path_io, temp_dir): 296 | with universal_exception_reason(FileNotFoundError): 297 | await path_io.rename(temp_dir / "foo", temp_dir / "bar") 298 | with universal_exception_reason(FileNotFoundError): 299 | await path_io.rename(temp_dir, temp_dir / "foo" / "bar") 300 | 301 | 302 | @pytest.mark.asyncio 303 | async def test_rename_over_exists(path_io, temp_dir): 304 | source = temp_dir / "source" 305 | destination = temp_dir / "destination" 306 | await path_io.mkdir(source) 307 | await path_io.mkdir(destination) 308 | await path_io.rename(source, destination) 309 | -------------------------------------------------------------------------------- /tests/test_permissions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aioftp 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_permission_denied( 8 | pair_factory, 9 | Server, 10 | expect_codes_in_exception, 11 | ): 12 | s = Server( 13 | [ 14 | aioftp.User(permissions=[aioftp.Permission(writable=False)]), 15 | ], 16 | ) 17 | async with pair_factory(None, s) as pair: 18 | with expect_codes_in_exception("550"): 19 | await pair.client.make_directory("foo") 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_permission_overriden(pair_factory, Server): 24 | s = Server( 25 | [ 26 | aioftp.User( 27 | permissions=[ 28 | aioftp.Permission("/", writable=False), 29 | aioftp.Permission("/foo"), 30 | ], 31 | ), 32 | ], 33 | ) 34 | async with pair_factory(None, s) as pair: 35 | await pair.client.make_directory("foo") 36 | await pair.client.remove_directory("foo") 37 | -------------------------------------------------------------------------------- /tests/test_restart.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize("offset", [0, 3, 10]) 5 | @pytest.mark.asyncio 6 | async def test_restart_retr(pair_factory, offset): 7 | async with pair_factory() as pair: 8 | atom = b"foobar" 9 | name = "foo.txt" 10 | await pair.make_server_files(name, size=1, atom=atom) 11 | async with pair.client.download_stream(name, offset=offset) as stream: 12 | assert await stream.read() == atom[offset:] 13 | 14 | 15 | @pytest.mark.parametrize("offset", [1, 3, 10]) 16 | @pytest.mark.parametrize("method", ["upload_stream", "append_stream"]) 17 | @pytest.mark.asyncio 18 | async def test_restart_stor_appe(pair_factory, offset, method): 19 | async with pair_factory() as pair: 20 | atom = b"foobar" 21 | name = "foo.txt" 22 | insert = b"123" 23 | expect = atom[:offset] + b"\x00" * (offset - len(atom)) + insert + atom[offset + len(insert) :] 24 | await pair.make_server_files(name, size=1, atom=atom) 25 | stream_factory = getattr(pair.client, method) 26 | async with stream_factory(name, offset=offset) as stream: 27 | await stream.write(b"123") 28 | async with pair.client.download_stream(name) as stream: 29 | assert await stream.read() == expect 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_restart_reset(pair_factory): 34 | async with pair_factory() as pair: 35 | atom = b"foobar" 36 | name = "foo.txt" 37 | await pair.make_server_files(name, size=1, atom=atom) 38 | await pair.client.command("REST 3", "350") 39 | async with pair.client.download_stream("foo.txt") as stream: 40 | assert await stream.read() == atom 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_restart_syntax_error(pair_factory, expect_codes_in_exception): 45 | async with pair_factory() as pair: 46 | with expect_codes_in_exception("501"): 47 | await pair.client.command("REST 3abc", "350") 48 | -------------------------------------------------------------------------------- /tests/test_simple_functions.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime 3 | import itertools 4 | import pathlib 5 | 6 | import pytest 7 | 8 | import aioftp 9 | 10 | 11 | def test_parse_directory_response(): 12 | s = 'foo "baz "" test nop" """""fdfs """' 13 | parsed = aioftp.Client.parse_directory_response(s) 14 | assert parsed == pathlib.PurePosixPath('baz " test nop') 15 | 16 | 17 | def test_connection_del_future(): 18 | loop = asyncio.new_event_loop() 19 | c = aioftp.Connection(loop=loop) 20 | c.foo = "bar" 21 | del c.future.foo 22 | 23 | 24 | def test_connection_not_in_storage(): 25 | loop = asyncio.new_event_loop() 26 | c = aioftp.Connection(loop=loop) 27 | with pytest.raises(AttributeError): 28 | getattr(c, "foo") 29 | 30 | 31 | def test_available_connections_too_much_acquires(): 32 | ac = aioftp.AvailableConnections(3) 33 | ac.acquire() 34 | ac.acquire() 35 | ac.acquire() 36 | with pytest.raises(ValueError): 37 | ac.acquire() 38 | 39 | 40 | def test_available_connections_too_much_releases(): 41 | ac = aioftp.AvailableConnections(3) 42 | ac.acquire() 43 | ac.release() 44 | with pytest.raises(ValueError): 45 | ac.release() 46 | 47 | 48 | def test_parse_pasv_response(): 49 | p = aioftp.Client.parse_pasv_response 50 | assert p("(192,168,1,0,1,0)") == ("192.168.1.0", 256) 51 | 52 | 53 | def test_parse_epsv_response(): 54 | p = aioftp.Client.parse_epsv_response 55 | assert p("some text (ha-ha) (|||665|) ((((666() (|fd667s).") == (None, 666) 56 | assert p("some text (ha-ha) (|||665|) (6666666).") == (None, 666) 57 | 58 | 59 | def _c_locale_time(d, format="%b %d %H:%M"): 60 | with aioftp.common.setlocale("C"): 61 | return d.strftime(format) 62 | 63 | 64 | def test_parse_ls_date_of_leap_year(): 65 | def date_to_p(d): 66 | return d.strftime("%Y%m%d%H%M00") 67 | 68 | p = aioftp.Client.parse_ls_date 69 | # Leap year date to test 70 | d = datetime.datetime(year=2000, month=2, day=29) 71 | current_and_expected_dates = ( 72 | # 2016 (leap) 73 | ( 74 | datetime.datetime(year=2016, month=2, day=29), 75 | datetime.datetime(year=2016, month=2, day=29), 76 | ), 77 | # 2017 78 | ( 79 | datetime.datetime(year=2017, month=2, day=28), 80 | datetime.datetime(year=2016, month=2, day=29), 81 | ), 82 | ( 83 | datetime.datetime(year=2017, month=3, day=1), 84 | datetime.datetime(year=2016, month=2, day=29), 85 | ), 86 | # 2018 87 | ( 88 | datetime.datetime(year=2018, month=2, day=28), 89 | datetime.datetime(year=2016, month=2, day=29), 90 | ), 91 | ( 92 | datetime.datetime(year=2018, month=3, day=1), 93 | datetime.datetime(year=2020, month=2, day=29), 94 | ), 95 | # 2019 96 | ( 97 | datetime.datetime(year=2019, month=2, day=28), 98 | datetime.datetime(year=2020, month=2, day=29), 99 | ), 100 | ( 101 | datetime.datetime(year=2019, month=3, day=1), 102 | datetime.datetime(year=2020, month=2, day=29), 103 | ), 104 | # 2020 (leap) 105 | ( 106 | datetime.datetime(year=2020, month=2, day=29), 107 | datetime.datetime(year=2020, month=2, day=29), 108 | ), 109 | ) 110 | for now, expected in current_and_expected_dates: 111 | assert p(_c_locale_time(d), now=now) == date_to_p(expected) 112 | 113 | 114 | def test_parse_ls_date_not_older_than_6_month_format(): 115 | def date_to_p(d): 116 | return d.strftime("%Y%m%d%H%M00") 117 | 118 | p = aioftp.Client.parse_ls_date 119 | dates = ( 120 | datetime.datetime(year=2002, month=1, day=1), 121 | datetime.datetime(year=2002, month=12, day=31), 122 | ) 123 | dt = datetime.timedelta(seconds=15778476 // 2) 124 | deltas = (datetime.timedelta(), dt, -dt) 125 | for now, delta in itertools.product(dates, deltas): 126 | d = now + delta 127 | assert p(_c_locale_time(d), now=now) == date_to_p(d) 128 | 129 | 130 | def test_parse_ls_date_older_than_6_month_format(): 131 | def date_to_p(d): 132 | return d.strftime("%Y%m%d%H%M00") 133 | 134 | p = aioftp.Client.parse_ls_date 135 | dates = ( 136 | datetime.datetime(year=2002, month=1, day=1), 137 | datetime.datetime(year=2002, month=12, day=31), 138 | ) 139 | dt = datetime.timedelta(seconds=15778476, days=30) 140 | deltas = (dt, -dt) 141 | for now, delta in itertools.product(dates, deltas): 142 | d = now + delta 143 | if delta.total_seconds() > 0: 144 | expect = date_to_p(d.replace(year=d.year - 1)) 145 | else: 146 | expect = date_to_p(d.replace(year=d.year + 1)) 147 | assert p(_c_locale_time(d), now=now) == expect 148 | 149 | 150 | def test_parse_ls_date_short(): 151 | def date_to_p(d): 152 | return d.strftime("%Y%m%d%H%M00") 153 | 154 | p = aioftp.Client.parse_ls_date 155 | dates = ( 156 | datetime.datetime(year=2002, month=1, day=1), 157 | datetime.datetime(year=2002, month=12, day=31), 158 | ) 159 | for d in dates: 160 | s = _c_locale_time(d, format="%b %d %Y") 161 | assert p(s) == date_to_p(d) 162 | 163 | 164 | def test_parse_list_line_unix(): 165 | lines = { 166 | "file": [ 167 | "-rw-rw-r-- 1 poh poh 6595 Feb 27 04:14 history.rst", 168 | "lrwxrwxrwx 1 poh poh 6 Mar 23 05:46 link-tmp.py -> tmp.py", 169 | ], 170 | "dir": [ 171 | "drw-rw-r-- 1 poh poh 6595 Feb 27 04:14 history.rst", 172 | "drw-rw-r-- 1 poh poh 6595 Jan 03 2016 changes.rst", 173 | "drw-rw-r-- 1 poh poh 6595 Mar 10 1996 README.rst", 174 | ], 175 | "unknown": [ 176 | "Erw-rw-r-- 1 poh poh 6595 Feb 27 04:14 history.rst", 177 | ], 178 | } 179 | p = aioftp.Client(encoding="utf-8").parse_list_line_unix 180 | for t, stack in lines.items(): 181 | for line in stack: 182 | _, parsed = p(line.encode("utf-8")) 183 | assert parsed["type"] == t 184 | with pytest.raises(ValueError): 185 | p(b"-rw-rw-r-- 1 poh poh 6xx5 Feb 27 04:14 history.rst") 186 | with pytest.raises(ValueError): 187 | p(b"-rw-rw-r-- xx poh poh 6595 Feb 27 04:14 history.rst") 188 | 189 | _, parsed = p(b"lrwxrwxrwx 1 poh poh 6 Mar 23 05:46 link-tmp.py -> tmp.py") 190 | assert parsed["type"] == "file" 191 | assert parsed["link_dst"] == "tmp.py" 192 | 193 | _, parsed = p(b"lrwxrwxrwx 1 poh poh 6 Mar 23 05:46 link-tmp.py -> /tmp.py") 194 | assert parsed["type"] == "file" 195 | assert parsed["link_dst"] == "/tmp.py" 196 | 197 | 198 | @pytest.mark.parametrize("owner", ["s", "x", "-", "E"]) 199 | @pytest.mark.parametrize("group", ["s", "x", "-", "E"]) 200 | @pytest.mark.parametrize("others", ["t", "x", "-", "E"]) 201 | @pytest.mark.parametrize("read", ["r", "-"]) 202 | @pytest.mark.parametrize("write", ["w", "-"]) 203 | def test_parse_unix_mode(owner, group, others, read, write): 204 | s = f"{read}{write}{owner}{read}{write}{group}{read}{write}{others}" 205 | if "E" in {owner, group, others}: 206 | with pytest.raises(ValueError): 207 | aioftp.Client.parse_unix_mode(s) 208 | else: 209 | assert isinstance(aioftp.Client.parse_unix_mode(s), int) 210 | 211 | 212 | def test_parse_list_line_failed(): 213 | with pytest.raises(ValueError): 214 | aioftp.Client(encoding="utf-8").parse_list_line(b"what a hell?!") 215 | 216 | 217 | def test_reprs_works(): 218 | repr(aioftp.Throttle()) 219 | repr(aioftp.Permission()) 220 | repr(aioftp.User()) 221 | 222 | 223 | def test_throttle_reset(): 224 | t = aioftp.Throttle(limit=1, reset_rate=1) 225 | t.append(b"-" * 3, 0) 226 | assert t._start == 0 227 | assert t._sum == 3 228 | t.append(b"-" * 3, 2) 229 | assert t._start == 2 230 | assert t._sum == 4 231 | 232 | 233 | def test_permission_is_parent(): 234 | p = aioftp.Permission("/foo/bar") 235 | assert p.is_parent(pathlib.PurePosixPath("/foo/bar/baz")) 236 | assert not p.is_parent(pathlib.PurePosixPath("/bar/baz")) 237 | 238 | 239 | def test_server_mtime_build(): 240 | now = datetime.datetime(year=2002, month=1, day=1).timestamp() 241 | past = datetime.datetime(year=2001, month=1, day=1).timestamp() 242 | b = aioftp.Server.build_list_mtime 243 | assert b(now, now) == "Jan 1 00:00" 244 | assert b(past, now) == "Jan 1 2001" 245 | 246 | 247 | def test_get_paths_windows_traverse(): 248 | base_path = pathlib.PureWindowsPath("C:\\ftp") 249 | user = aioftp.User() 250 | user.base_path = base_path 251 | connection = aioftp.Connection(current_directory=base_path, user=user) 252 | virtual_path = pathlib.PurePosixPath("/foo/C:\\windows") 253 | real_path, resolved_virtual_path = aioftp.Server.get_paths( 254 | connection, 255 | virtual_path, 256 | ) 257 | assert real_path == pathlib.PureWindowsPath("C:/ftp/foo/C:/windows") 258 | assert resolved_virtual_path == pathlib.PurePosixPath("/foo/C:\\windows") 259 | -------------------------------------------------------------------------------- /tests/test_throttle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import reduce 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | import aioftp 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_patched_sleep(skip_sleep): 12 | await asyncio.sleep(10) 13 | assert skip_sleep.is_close(10) 14 | 15 | 16 | SIZE = 3 * 100 * 1024 # 300KiB 17 | 18 | 19 | @pytest.mark.parametrize("times", [10, 20, 30]) 20 | @pytest.mark.parametrize("type", ["read", "write"]) 21 | @pytest.mark.parametrize("direction", ["download", "upload"]) 22 | @pytest.mark.asyncio 23 | async def test_client_side_throttle( 24 | pair_factory, 25 | skip_sleep, 26 | times, 27 | type, 28 | direction, 29 | ): 30 | async with pair_factory() as pair: 31 | await pair.make_server_files("foo", size=SIZE) 32 | await pair.make_client_files("foo", size=SIZE) 33 | getattr(pair.client.throttle, type).limit = SIZE / times 34 | await getattr(pair.client, direction)("foo") 35 | if (type, direction) in {("read", "download"), ("write", "upload")}: 36 | assert skip_sleep.is_close(times) 37 | else: 38 | assert skip_sleep.is_close(0) 39 | 40 | 41 | @pytest.mark.parametrize("times", [10, 20, 30]) 42 | @pytest.mark.parametrize("users", [1, 2, 3]) 43 | @pytest.mark.parametrize("throttle_direction", ["read", "write"]) 44 | @pytest.mark.parametrize("data_direction", ["download", "upload"]) 45 | @pytest.mark.parametrize( 46 | "throttle_level", 47 | [ 48 | "throttle", 49 | "throttle_per_connection", 50 | ], 51 | ) 52 | @pytest.mark.asyncio 53 | async def test_server_side_throttle( 54 | pair_factory, 55 | skip_sleep, 56 | times, 57 | users, 58 | throttle_direction, 59 | data_direction, 60 | throttle_level, 61 | ): 62 | async with pair_factory() as pair: 63 | names = [] 64 | for i in range(users): 65 | name = f"foo{i}" 66 | names.append(name) 67 | await pair.make_server_files(name, size=SIZE) 68 | throttle = reduce( 69 | getattr, 70 | [throttle_level, throttle_direction], 71 | pair.server, 72 | ) 73 | throttle.limit = SIZE / times 74 | clients = [] 75 | for name in names: 76 | c = aioftp.Client(path_io_factory=aioftp.MemoryPathIO) 77 | async with c.path_io.open(Path(name), "wb") as f: 78 | await f.write(b"-" * SIZE) 79 | await c.connect(pair.server.server_host, pair.server.server_port) 80 | await c.login() 81 | clients.append(c) 82 | coros = [getattr(c, data_direction)(n) for c, n in zip(clients, names)] 83 | await asyncio.gather(*coros) 84 | await asyncio.gather(*[c.quit() for c in clients]) 85 | throttled = {("read", "upload"), ("write", "download")} 86 | if (throttle_direction, data_direction) not in throttled: 87 | assert skip_sleep.is_close(0) 88 | else: 89 | t = times 90 | if throttle_level == "throttle": # global 91 | t *= users 92 | assert skip_sleep.is_close(t) 93 | -------------------------------------------------------------------------------- /tests/test_tls.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | if sys.version_info < (3, 11): 6 | pytest.skip(reason="required python 3.11+", allow_module_level=True) 7 | 8 | 9 | async def _auth_response(connection, rest): 10 | connection.response("234", ":P") 11 | return True 12 | 13 | 14 | async def _ok_response(connection, rest): 15 | connection.response("200", ":P") 16 | return True 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_upgrade_to_tls(mocker, pair_factory): 21 | ssl_context = object() 22 | create_default_context = mocker.patch("aioftp.client.ssl.create_default_context", return_value=ssl_context) 23 | 24 | async with pair_factory(logged=False, do_quit=False) as pair: 25 | pair.server.commands_mapping["auth"] = _auth_response 26 | 27 | start_tls = mocker.patch.object(pair.client.stream, "start_tls") 28 | command_spy = mocker.spy(pair.client, "command") 29 | 30 | await pair.client.upgrade_to_tls() 31 | 32 | create_default_context.assert_called_once_with() 33 | start_tls.assert_called_once_with(sslcontext=ssl_context, server_hostname=pair.client.server_host) 34 | command_spy.assert_called_once_with("AUTH TLS", "234") 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_upgrade_to_tls_custom_ssl_context(mocker, pair_factory): 39 | ssl_context = object() 40 | create_default_context = mocker.patch("aioftp.client.ssl.create_default_context") 41 | 42 | async with pair_factory(logged=False, do_quit=False) as pair: 43 | pair.server.commands_mapping["auth"] = _auth_response 44 | 45 | start_tls = mocker.patch.object(pair.client.stream, "start_tls") 46 | command_spy = mocker.spy(pair.client, "command") 47 | 48 | await pair.client.upgrade_to_tls(sslcontext=ssl_context) 49 | 50 | create_default_context.assert_not_called() 51 | start_tls.assert_called_once_with(sslcontext=ssl_context, server_hostname=pair.client.server_host) 52 | command_spy.assert_called_once_with("AUTH TLS", "234") 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_upgrade_to_tls_does_nothing_when_already_updated(mocker, pair_factory): 57 | mocker.patch("aioftp.client.ssl.create_default_context") 58 | 59 | async with pair_factory(logged=False, do_quit=False) as pair: 60 | pair.client._upgraded_to_tls = True 61 | 62 | start_tls = mocker.patch.object(pair.client.stream, "start_tls") 63 | command_spy = mocker.spy(pair.client, "command") 64 | 65 | await pair.client.upgrade_to_tls() 66 | 67 | start_tls.assert_not_called() 68 | command_spy.assert_not_called() 69 | 70 | 71 | @pytest.mark.asyncio 72 | async def test_upgrade_to_tls_when_logged_in(mocker, pair_factory): 73 | mocker.patch("aioftp.client.ssl.create_default_context") 74 | 75 | async with pair_factory(logged=False, do_quit=False) as pair: 76 | pair.server.commands_mapping["auth"] = _auth_response 77 | pair.server.commands_mapping["pbsz"] = _ok_response 78 | pair.server.commands_mapping["prot"] = _ok_response 79 | pair.client._logged_in = True 80 | 81 | mocker.patch.object(pair.client.stream, "start_tls") 82 | command_spy = mocker.spy(pair.client, "command") 83 | 84 | await pair.client.upgrade_to_tls() 85 | 86 | command_spy.assert_has_calls( 87 | [ 88 | mocker.call("AUTH TLS", "234"), 89 | mocker.call("PBSZ 0", "200"), 90 | mocker.call("PROT P", "200"), 91 | ], 92 | ) 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_login_should_send_tls_protection_when_upgraded(mocker, pair_factory): 97 | mocker.patch("aioftp.client.ssl.create_default_context") 98 | 99 | async with pair_factory(logged=False, do_quit=False) as pair: 100 | pair.server.commands_mapping["pbsz"] = _ok_response 101 | pair.server.commands_mapping["prot"] = _ok_response 102 | pair.client._upgraded_to_tls = True 103 | 104 | command_spy = mocker.spy(pair.client, "command") 105 | 106 | await pair.client.login("foo", "bar") 107 | 108 | command_spy.assert_has_calls( 109 | [ 110 | mocker.call("PBSZ 0", "200"), 111 | mocker.call("PROT P", "200"), 112 | ], 113 | ) 114 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import aioftp 4 | 5 | 6 | def test_user_not_absolute_home(): 7 | with pytest.raises(aioftp.errors.PathIsNotAbsolute): 8 | aioftp.User(home_path="foo") 9 | --------------------------------------------------------------------------------