├── tests ├── __init__.py ├── not_a_valid_cert.pem ├── test_pytest_localftpserver_with_env_var.py ├── test_keycert.pem ├── test_pytest_localftpserver_TLS.py ├── test_helper_functions.py └── test_pytest_localftpserver.py ├── docs ├── authors.rst ├── history.rst ├── readme.rst ├── contributing.rst ├── _templates │ └── autosummary │ │ ├── method.rst │ │ └── class.rst ├── api_doc.rst ├── index.rst ├── Makefile ├── make.bat ├── installation.rst ├── conf.py └── usage.rst ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── CI_CD_actions.yml ├── pytest_localftpserver ├── __init__.py ├── plugin.py ├── default_keycert.pem ├── helper_functions.py └── servers.py ├── readthedocs.yml ├── AUTHORS.rst ├── requirements_dev.txt ├── MANIFEST.in ├── .editorconfig ├── HISTORY.rst ├── .coveragerc ├── .gitignore ├── pyproject.toml ├── LICENSE ├── tox.ini ├── Makefile ├── CONTRIBUTING.rst └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/not_a_valid_cert.pem: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: oz123 4 | -------------------------------------------------------------------------------- /pytest_localftpserver/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = """Oz Tiram""" 2 | __email__ = 'oz.tiram@gmail.com' 3 | __version__ = '1.1.4' 4 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/method.rst: -------------------------------------------------------------------------------- 1 | {{ name | escape | underline }} 2 | 3 | .. automethod:: {{ fullname }} 4 | :noindex: 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | formats: all 4 | 5 | build: 6 | image: latest 7 | 8 | python: 9 | version: 3.7 10 | install: 11 | - requirements: requirements_dev.txt 12 | - method: pip 13 | path: . 14 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Oz Tiram 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Sebastian Weigand 14 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bump2version>=0.5.10 2 | wheel>=0.30.0 3 | 4 | # install requirements 5 | 6 | 7 | pyftpdlib>=2.0.0 8 | pyOpenSSL>=24.1.0 9 | pytest>=9.0.0 10 | 11 | 12 | 13 | 14 | # documentation 15 | Sphinx>=1.8 16 | sphinx-copybutton>=0.3.0 17 | sphinx-rtd-theme>=0.5.0 18 | 19 | # testing 20 | flake8>=2.6.0 21 | tox==4.32.0 22 | coverage>=4.3.0 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include AUTHORS.rst 3 | 4 | include CONTRIBUTING.rst 5 | include HISTORY.rst 6 | include LICENSE 7 | include README.rst 8 | include pytest_localftpserver/default_keycert.pem 9 | 10 | recursive-include tests * 11 | recursive-exclude * __pycache__ 12 | recursive-exclude * *.py[co] 13 | 14 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 15 | -------------------------------------------------------------------------------- /docs/api_doc.rst: -------------------------------------------------------------------------------- 1 | .. _API Documentation: 2 | 3 | ================= 4 | API Documentation 5 | ================= 6 | 7 | This is the detailed documentation of ``FunctionalityWrapper``, which 8 | holds all the functionality you gain by ``PyTest local FTP Server``. 9 | 10 | .. currentmodule:: pytest_localftpserver.servers 11 | 12 | .. autosummary:: 13 | :nosignatures: 14 | :toctree: api/ 15 | 16 | FunctionalityWrapper 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [*.yml] 18 | indent_size = 2 19 | 20 | [LICENSE] 21 | insert_final_newline = false 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to PyTest local FTP Server's documentation! 2 | =================================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | readme 10 | installation 11 | usage 12 | api_doc 13 | contributing 14 | authors 15 | history 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | History 2 | ======= 3 | 4 | 1.0.1 (2019-12-10) 5 | ------------------ 6 | 7 | * Include the certificate in the source package 8 | * Use a bigger certificate 9 | 10 | 1.0.0 (2019-09-05) 11 | ------------------ 12 | 13 | * Dropped support for Python 2.7 and 3.4 14 | 15 | 0.6.0 - released as tag only 16 | ---------------------------- 17 | 18 | * Added fixture scope configuration. 19 | * Added ``ftpserver_TLS`` as TLS version of the fixture. 20 | 21 | 0.5.0 (2018-12-04) 22 | ------------------ 23 | 24 | * Added support for Windows. 25 | * Added hightlevel interface. 26 | 27 | 0.1.0 (2016-12-09) 28 | ------------------ 29 | 30 | * First release on PyPI. 31 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = pytest_localftpserver/* 4 | # tests/* 5 | # uncomment the above line if you want to see if all tests did run 6 | omit = setup.py 7 | pytest_localftpserver/__init__.py 8 | tests/__init__.py 9 | 10 | [report] 11 | # Regexes for lines to exclude from consideration 12 | exclude_lines = 13 | # Have to re-enable the standard pragma 14 | pragma: no cover 15 | 16 | # Don't complain about missing debug-only code: 17 | def __repr__ 18 | if self\.debug 19 | 20 | # Don't complain if tests don't hit defensive assertion code: 21 | raise AssertionError 22 | raise NotImplementedError 23 | 24 | # Don't complain if non-runnable code isn't run: 25 | if 0: 26 | if __name__ == .__main__.: 27 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = -W 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = glotaran 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | API_TOCTREE_DIR = api 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 15 | 16 | .PHONY: help Makefile 17 | 18 | clean_all: 19 | rm -rf $(BUILDDIR)/* 20 | rm -rf $(API_TOCTREE_DIR)/* 21 | 22 | # Catch-all target: route all unknown targets to Sphinx using the new 23 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 24 | %: Makefile 25 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 26 | -------------------------------------------------------------------------------- /docs/_templates/autosummary/class.rst: -------------------------------------------------------------------------------- 1 | {{ objname | escape | underline}} 2 | 3 | .. currentmodule:: {{ module }} 4 | 5 | .. autoclass:: {{ objname }} 6 | 7 | {% block attributes_summary %} 8 | {% if attributes %} 9 | 10 | .. rubric:: Attributes Summary 11 | 12 | .. autosummary:: 13 | {% for item in attributes %} 14 | ~{{ item }} 15 | {%- endfor %} 16 | 17 | {% endif %} 18 | {% endblock %} 19 | 20 | {% block methods_summary %} 21 | {% if methods %} 22 | 23 | {% if '__init__' in methods %} 24 | {% set caught_result = methods.remove('__init__') %} 25 | {% endif %} 26 | 27 | .. rubric:: Methods Summary 28 | 29 | .. autosummary:: 30 | :toctree: {{ objname }}/methods 31 | :nosignatures: 32 | 33 | {% for item in methods %} 34 | ~{{ name }}.{{ item }} 35 | {%- endfor %} 36 | 37 | {% endif %} 38 | {% endblock %} 39 | 40 | {% block methods_documentation %} 41 | {% if methods %} 42 | 43 | .. rubric:: Methods Documentation 44 | 45 | {% for item in methods %} 46 | .. automethod:: {{ name }}.{{ item }} 47 | {%- endfor %} 48 | 49 | {% endif %} 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set API_TOCTREE_DIR=api 13 | set SPHINXPROJ=pytest_localftpserver 14 | set SPHINXOPTS=-W 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "clean_all" ( 19 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 20 | del /q /s %BUILDDIR%\* 21 | for /d %%i in (%API_TOCTREE_DIR%\*) do rmdir /q /s %%i 22 | del /q /s %API_TOCTREE_DIR%\* 23 | goto end 24 | ) 25 | 26 | %SPHINXBUILD% >NUL 2>NUL 27 | if errorlevel 9009 ( 28 | echo. 29 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 30 | echo.then set the SPHINXBUILD environment variable to point to the full 31 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 32 | echo.Sphinx directory to PATH. 33 | echo. 34 | echo.If you don't have Sphinx installed, grab it from 35 | echo.http://sphinx-doc.org/ 36 | exit /b 1 37 | ) 38 | 39 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 40 | goto end 41 | 42 | :help 43 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 44 | 45 | 46 | :end 47 | popd 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # editor project config 10 | # pycharm 11 | .idea 12 | 13 | # Distribution / packaging 14 | .Python 15 | env/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | coverage_*.xml 47 | htmlcov_* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *,cover 52 | .hypothesis 53 | .pytest_cache 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | docs/api/ 65 | 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # pyenv python configuration file 71 | .python-version 72 | 73 | # vs-code config files 74 | .vscode 75 | pytest_localftpserver/_version.py 76 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install PyTest FTP Server, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install pytest-localftpserver 16 | 17 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 18 | you through the process. 19 | 20 | Or if you prefer to use `conda`_: 21 | 22 | .. code-block:: console 23 | 24 | $ conda install -c conda-forge pytest-localftpserver 25 | 26 | This are the preferred methods to install PyTest FTP Server, as it will always install the most recent stable release. 27 | 28 | .. _pip: https://pip.pypa.io/en/stable/ 29 | .. _conda: https://www.anaconda.com/products/individual 30 | .. _Python installation guide: https://docs.python-guide.org/starting/installation/ 31 | 32 | 33 | From sources 34 | ------------ 35 | 36 | The sources for PyTest FTP Server can be downloaded from the `Github repo`_. 37 | 38 | You can either clone the public repository: 39 | 40 | .. code-block:: console 41 | 42 | $ git clone git://github.com/oz123/pytest-localftpserver 43 | 44 | Or download the `tarball`_: 45 | 46 | .. code-block:: console 47 | 48 | $ curl -OL https://github.com/oz123/pytest-localftpserver/tarball/master 49 | 50 | Once you have a copy of the source, you can install it with: 51 | 52 | .. code-block:: console 53 | 54 | $ python setup.py install 55 | 56 | 57 | .. _Github repo: https://github.com/oz123/pytest-localftpserver 58 | .. _tarball: https://github.com/oz123/pytest-localftpserver/tarball/master 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=67.0.0", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pytest_localftpserver" 7 | dynamic = ["version"] 8 | description="A PyTest plugin which provides an FTP fixture for your tests" 9 | authors = [ 10 | {name = "Oz N Tiram", email = "oz.tiram@gmail.com"}, 11 | ] 12 | readme = "README.rst" 13 | classifiers = [ 14 | 'Development Status :: 5 - Production/Stable', 15 | 'Intended Audience :: Developers', 16 | 'Operating System :: MacOS :: MacOS X', 17 | 'Operating System :: Microsoft :: Windows', 18 | 'Operating System :: POSIX :: Linux', 19 | 'Operating System :: POSIX', 20 | 'License :: OSI Approved :: MIT License', 21 | 'Natural Language :: English', 22 | 'Programming Language :: Python :: 3', 23 | 'Programming Language :: Python :: 3.10', 24 | 'Programming Language :: Python :: 3.11', 25 | 'Programming Language :: Python :: 3.12', 26 | 'Programming Language :: Python :: 3.13', 27 | 'Programming Language :: Python :: 3 :: Only', 28 | 'Framework :: Pytest', 29 | 'Topic :: Software Development :: Testing', 30 | 'Topic :: Software Development :: Testing :: Mocking' 31 | ] 32 | 33 | dependencies = [ 34 | 'pyftpdlib>=1.2.0', 35 | 'PyOpenSSL', 36 | 'pytest', 37 | 'cryptography>=43' 38 | ] 39 | 40 | [tool.setuptools] 41 | packages = ["pytest_localftpserver"] 42 | 43 | [project.entry-points.pytest11] 44 | localftpserver = "pytest_localftpserver.plugin" 45 | 46 | [project.urls] 47 | homepage = 'https://github.com/oz123/pytest-localftpserver' 48 | 49 | [tool.setuptools_scm] 50 | write_to = "pytest_localftpserver/_version.py" 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2016, Oz Tiram 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | ----- 14 | Parts of this package are based on work by Cory Benfield 15 | 16 | Copyright 2012 Cory Benfield 17 | 18 | Licensed under the Apache License, Version 2.0 (the "License"); 19 | you may not use this file except in compliance with the License. 20 | You may obtain a copy of the License at 21 | 22 | http://www.apache.org/licenses/LICENSE-2.0 23 | 24 | Unless required by applicable law or agreed to in writing, software 25 | distributed under the License is distributed on an "AS IS" BASIS, 26 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 27 | See the License for the specific language governing permissions and 28 | limitations under the License. 29 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.4.0 3 | envlist = py{310,311,312,313}, custom_config, flake8, docs, docs-links 4 | 5 | 6 | [gh-actions] 7 | python = 8 | 3.10: py310 9 | 3.11: py311 10 | 3.12: py312 11 | 3.13: py313 12 | 13 | 14 | [flake8] 15 | max-line-length = 99 16 | 17 | 18 | [testenv:docs] 19 | whitelist_externals = make 20 | commands = 21 | make --directory=docs clean_all html 22 | 23 | [testenv:docs-links] 24 | whitelist_externals = make 25 | commands = 26 | make --directory=docs clean_all linkcheck 27 | 28 | 29 | [testenv:flake8] 30 | basepython=python 31 | deps=flake8 32 | commands=flake8 pytest_localftpserver tests 33 | 34 | 35 | [testenv:custom_config] 36 | setenv = 37 | FTP_USER = benz 38 | FTP_PASS = erni1 39 | FTP_HOME = {envtmpdir} 40 | FTP_HOME_TLS = {envtmpdir} 41 | FTP_PORT = 31175 42 | FTP_PORT_TLS = 31176 43 | FTP_FIXTURE_SCOPE = function 44 | FTP_CERTFILE = {toxinidir}/tests/test_keycert.pem 45 | commands_pre = 46 | {envpython} -m pip install -U -q -r {toxinidir}/requirements_dev.txt 47 | commands = 48 | {env:COMMAND:coverage} erase 49 | {env:COMMAND:coverage} run --append -m pytest --basetemp={envtmpdir} tests/test_pytest_localftpserver_with_env_var.py 50 | {env:COMMAND:coverage} report --show-missing 51 | 52 | 53 | [testenv] 54 | passenv = * 55 | deps = -r{toxinidir}/requirements_dev.txt 56 | commands_pre = 57 | {envpython} -m pip install -U -q 'cryptography>=43' --only-binary=:all: 58 | {envpython} -m pip install -U -q -r {toxinidir}/requirements_dev.txt 59 | commands = 60 | {env:COMMAND:coverage} erase 61 | {env:COMMAND:coverage} run --append -m pytest --basetemp={envtmpdir} tests/test_pytest_localftpserver.py tests/test_helper_functions.py tests/test_pytest_localftpserver_TLS.py 62 | {env:COMMAND:coverage} report --show-missing 63 | -------------------------------------------------------------------------------- /pytest_localftpserver/plugin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pytest 4 | 5 | from .servers import PytestLocalFTPServer 6 | from .helper_functions import get_scope 7 | 8 | # uncomment the next line to log _option_validator for debugging 9 | # import logging 10 | # logging.basicConfig(filename='option_validator.log', level=logging.INFO) 11 | 12 | FIXTURE_SCOPE = get_scope() 13 | 14 | 15 | @pytest.fixture(scope=FIXTURE_SCOPE) 16 | def ftpserver(request): 17 | """The returned ``ftpsever`` provides a threaded instance of 18 | ``pyftpdlib.servers.FTPServer`` running on localhost. 19 | 20 | For details on the usage and configuration check out the docs at: 21 | https://pytest-localftpserver.readthedocs.io/en/latest/usage.html 22 | 23 | Yields 24 | ------ 25 | ftpserver: FunctionalityWrapper 26 | The type of `ftpserver` isn't actually `FunctionalityWrapper`, 27 | but a subclass with `threading.Thread` or `multiprocessing.Process` 28 | depending on the OS. But for autocomplete sake and since 29 | `FunctionalityWrapper` holds all functionality, let's pretend that it is 30 | `FunctionalityWrapper`. 31 | """ 32 | server = PytestLocalFTPServer() 33 | request.addfinalizer(server.stop) 34 | return server 35 | 36 | 37 | @pytest.fixture(scope=FIXTURE_SCOPE) 38 | def ftpserver_TLS(request): 39 | """The returned ``ftpsever_TSL`` provides a threaded instance of 40 | ``pyftpdlib.servers.FTPServer`` using TSL and running on localhost. 41 | 42 | For details on the usage and configuration check out the docs at: 43 | https://pytest-localftpserver.readthedocs.io/en/latest/usage.html 44 | 45 | Yields 46 | ------ 47 | ftpserver: FunctionalityWrapper 48 | The type of `ftpsever_TSL` isn't actually `FunctionalityWrapper`, 49 | but a subclass with `threading.Thread` or `multiprocessing.Process` 50 | depending on the OS. But for autocomplete sake and since 51 | `FunctionalityWrapper` holds all functionality, let's pretend that it is 52 | `FunctionalityWrapper`. 53 | """ 54 | server = PytestLocalFTPServer(use_TLS=True) 55 | request.addfinalizer(server.stop) 56 | return server 57 | -------------------------------------------------------------------------------- /tests/test_pytest_localftpserver_with_env_var.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from ftplib import FTP 3 | import os 4 | 5 | import pytest 6 | from pytest_localftpserver.helper_functions import get_env_dict 7 | 8 | from .test_pytest_localftpserver import run_ftp_stopped_test 9 | 10 | 11 | # uncomment the next line to log _option_validator for debugging 12 | # import logging 13 | # logging.basicConfig(filename='option_validator.log', level=logging.INFO) 14 | 15 | 16 | @pytest.mark.parametrize("use_TLS", [True, False]) 17 | def test_get_env_dict(use_TLS): 18 | result_dict = {} 19 | result_dict["username"] = "benz" 20 | result_dict["password"] = "erni1" 21 | result_dict["certfile"] = os.path.abspath( 22 | os.path.join(os.path.dirname(__file__), 23 | "test_keycert.pem")) 24 | if use_TLS: 25 | result_dict["ftp_home"] = os.getenv("FTP_HOME_TLS", "") 26 | result_dict["ftp_port"] = 31176 27 | else: 28 | result_dict["ftp_home"] = os.getenv("FTP_HOME", "") 29 | result_dict["ftp_port"] = 31175 30 | 31 | env_dict = get_env_dict(use_TLS=use_TLS) 32 | assert env_dict == result_dict 33 | 34 | 35 | def test_customize_server(ftpserver): 36 | assert ftpserver.server_port == 31175 37 | assert ftpserver.username == "benz" 38 | assert ftpserver.password == "erni1" 39 | assert ftpserver.server_home == os.getenv("FTP_HOME") 40 | 41 | 42 | def test_customize_server_TLS(ftpserver_TLS): 43 | assert ftpserver_TLS.server_port == 31176 44 | assert ftpserver_TLS.username == "benz" 45 | assert ftpserver_TLS.password == "erni1" 46 | assert ftpserver_TLS.server_home == os.getenv("FTP_HOME_TLS") 47 | 48 | 49 | # FUNCTION SCOPE TESTS 50 | 51 | 52 | def test_shutdown(ftpserver): 53 | assert ftpserver.server_port == 31175 54 | local_anon_path = ftpserver.get_local_base_path(anon=True) 55 | local_ftp_home = ftpserver.get_local_base_path(anon=False) 56 | run_ftp_stopped_test(ftpserver) 57 | # check if all anon home folders got cleared properly 58 | assert not os.path.exists(local_anon_path) 59 | # since home was provided via config it shouldn't be deleted 60 | assert os.path.exists(local_ftp_home) 61 | 62 | 63 | def test_server_running_again(ftpserver): 64 | local_anon_path = ftpserver.get_local_base_path(anon=True) 65 | local_ftp_home = ftpserver.get_local_base_path(anon=False) 66 | ftp = FTP() 67 | ftp.connect("localhost", port=ftpserver.server_port) 68 | assert ftpserver.server_port == 31175 69 | # check if all temp folders are recreated 70 | assert os.path.exists(local_anon_path) 71 | assert os.path.exists(local_ftp_home) 72 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | 14 | define PRINT_HELP_PYSCRIPT 15 | import re, sys 16 | 17 | for line in sys.stdin: 18 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 19 | if match: 20 | target, help = match.groups() 21 | print("%-20s %s" % (target, help)) 22 | endef 23 | export PRINT_HELP_PYSCRIPT 24 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 25 | 26 | 27 | DEFAULT_PY ?= 3.12 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | 35 | clean-build: ## remove build artifacts 36 | rm -fr build/ 37 | rm -fr dist/ 38 | rm -fr .eggs/ 39 | find . -name '*.egg-info' -exec rm -fr {} + 40 | find . -name '*.egg' -exec rm -f {} + 41 | 42 | clean-pyc: ## remove Python file artifacts 43 | find . -name '*.pyc' -exec rm -f {} + 44 | find . -name '*.pyo' -exec rm -f {} + 45 | find . -name '*~' -exec rm -f {} + 46 | find . -name '__pycache__' -exec rm -fr {} + 47 | 48 | clean-test: ## remove test and coverage artifacts 49 | rm -fr .tox/ 50 | rm -f .coverage 51 | rm -fr htmlcov/ 52 | 53 | lint: ## check style with flake8 54 | flake8 pytest_localftpserver tests 55 | 56 | test: ## run tests quickly with the default Python 57 | tox -e $(DEFAULT_PY) 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source pytest_localftpserver -m pytest 64 | 65 | coverage report -m 66 | coverage html 67 | $(BROWSER) htmlcov/index.html 68 | 69 | docs: ## generate Sphinx HTML documentation, including API docs 70 | rm -f docs/pytest_localftpserver.rst 71 | rm -f docs/modules.rst 72 | sphinx-apidoc -o docs/ pytest_localftpserver 73 | $(MAKE) -C docs clean 74 | $(MAKE) -C docs html 75 | $(BROWSER) docs/_build/html/index.html 76 | 77 | servedocs: docs ## compile the docs watching for changes 78 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 79 | 80 | release: clean dist ## package and upload a release 81 | twine upload dist/*.tar.gz 82 | 83 | dist: clean ## builds source and wheel package 84 | python setup.py sdist 85 | python setup.py bdist_wheel 86 | ls -l dist 87 | 88 | install: clean ## install the package to the active Python's site-packages 89 | python setup.py install 90 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/oz123/pytest-localftpserver/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" 30 | and "help wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | PyTest FTP Server could always use more documentation, whether as part of the 42 | official PyTest FTP Server docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/oz123/pytest-localftpserver/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `pytest_localftpserver` for local development. 61 | 62 | 1. Fork the `pytest-localftpserver` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/pytest-localftpserver.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, 68 | this is how you set up your fork for local development:: 69 | 70 | $ mkvirtualenv pytest_localftpserver 71 | $ cd pytest-localftpserver/ 72 | $ pip install -r requirements_dev.txt 73 | $ python setup.py develop 74 | 75 | 4. Create a branch for local development:: 76 | 77 | $ git checkout -b name-of-your-bugfix-or-feature 78 | 79 | Now you can make your changes locally. 80 | 81 | 5. When you're done making changes, check that your changes pass flake8 and the tests, 82 | including testing other Python versions with tox:: 83 | 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.6+. Check 106 | https://github.com/oz123/pytest-localftpserver/actions?query=workflow%3ATests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ pytest tests/test_pytest_localftpserver.py:: 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bump2version patch # possible: major / minor / patch 125 | $ git push --follow-tags 126 | 127 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PyTest FTP Server 2 | ================= 3 | 4 | 5 | .. image:: https://img.shields.io/pypi/v/pytest_localftpserver.svg 6 | :target: https://pypi.org/project/pytest-localftpserver/ 7 | 8 | .. image:: https://camo.githubusercontent.com/89b9f56d30241e30f546daf9f43653f08e920f16/68747470733a2f2f696d672e736869656c64732e696f2f636f6e64612f766e2f636f6e64612d666f7267652f7079746573742d6c6f63616c6674707365727665722e737667 9 | :target: https://anaconda.org/conda-forge/pytest-localftpserver 10 | 11 | .. image:: https://img.shields.io/pypi/pyversions/pytest_localftpserver.svg 12 | :target: https://pypi.org/project/pytest/ 13 | 14 | .. image:: https://github.com/oz123/pytest-localftpserver/workflows/Tests/badge.svg 15 | :target: https://github.com/oz123/pytest-localftpserver/actions?query=workflow%3ATests 16 | 17 | .. image:: https://readthedocs.org/projects/pytest-localftpserver/badge/?version=latest 18 | :target: https://pytest-localftpserver.readthedocs.io/en/latest/?badge=latest 19 | :alt: Documentation Status 20 | 21 | .. image:: https://coveralls.io/repos/github/oz123/pytest-localftpserver/badge.svg 22 | :target: https://coveralls.io/github/oz123/pytest-localftpserver 23 | :alt: Coverage 24 | 25 | 26 | A PyTest plugin which provides an FTP fixture for your tests 27 | 28 | 29 | * Free software: MIT license 30 | * Documentation: https://pytest-localftpserver.readthedocs.io/en/latest/index.html 31 | 32 | Attention! 33 | ---------- 34 | 35 | As of version ``1.0.0`` the support for python 2.7 and 3.4 was dropped. 36 | If you need to support those versions you should pin the version to ``0.6.0``, 37 | i.e. add the following lines to your "requirements_dev.txt":: 38 | 39 | # pytest_localftpserver==0.6.0 40 | https://github.com/oz123/pytest-localftpserver/archive/v0.6.0.zip 41 | 42 | 43 | Usage Quickstart: 44 | ----------------- 45 | 46 | This Plugin provides the fixtures ``ftpserver`` and ``ftpserver_TLS``, 47 | which are threaded instances of a FTP server, with which you can upload files and test FTP 48 | functionality. It can be configured using the following environment variables: 49 | 50 | ===================== ============================================================================= 51 | Environment variable Usage 52 | ===================== ============================================================================= 53 | ``FTP_USER`` Username of the registered user. 54 | ``FTP_PASS`` Password of the registered user. 55 | ``FTP_PORT`` Port for the normal ftp server to run on. 56 | ``FTP_HOME`` Home folder (host system) of the registered user. 57 | ``FTP_FIXTURE_SCOPE`` Scope/lifetime of the fixture. 58 | ``FTP_PORT_TLS`` Port for the TLS ftp server to run on. 59 | ``FTP_HOME_TLS`` Home folder (host system) of the registered user, used by the TLS ftp server. 60 | ``FTP_CERTFILE`` Certificate (host system) to be used by the TLS ftp server. 61 | ===================== ============================================================================= 62 | 63 | 64 | See the `tests directory `_ 65 | or the 66 | `documentation `_ 67 | for examples. 68 | 69 | You can either set environment variables on a system level or use tools such as 70 | `pytest-env `_ or 71 | `tox `_, to change the default settings of this plugin. 72 | Sample config for pytest-cov:: 73 | 74 | $ cat pytest.ini 75 | [pytest] 76 | env = 77 | FTP_USER=benz 78 | FTP_PASS=erni1 79 | FTP_HOME = /home/ftp_test 80 | FTP_PORT=31175 81 | FTP_FIXTURE_SCOPE=function 82 | # only affects ftpserver_TLS 83 | FTP_PORT_TLS = 31176 84 | FTP_HOME_TLS = /home/ftp_test_TLS 85 | FTP_CERTFILE = ./tests/test_keycert.pem 86 | 87 | 88 | Sample config for Tox:: 89 | 90 | $ cat tox.ini 91 | [tox] 92 | envlist = py{36,37,38,39,310} 93 | 94 | [testenv] 95 | setenv = 96 | FTP_USER=benz 97 | FTP_PASS=erni1 98 | FTP_HOME = {envtmpdir} 99 | FTP_PORT=31175 100 | FTP_FIXTURE_SCOPE=function 101 | # only affects ftpserver_TLS 102 | FTP_PORT_TLS = 31176 103 | FTP_HOME_TLS = /home/ftp_test_TLS 104 | FTP_CERTFILE = {toxinidir}/tests/test_keycert.pem 105 | commands = 106 | pytest tests 107 | 108 | Credits 109 | ------- 110 | 111 | This package was inspired by, https://pypi.org/project/pytest-localserver/ 112 | made by Sebastian Rahlf, which lacks an FTP server. 113 | 114 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 115 | 116 | .. _Cookiecutter: https://github.com/cookiecutter/cookiecutter 117 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 118 | -------------------------------------------------------------------------------- /.github/workflows/CI_CD_actions.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Run Quality Assurance 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v6 13 | with: 14 | python-version: 3.11 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install -U flake8 18 | - name: Lint code 19 | run: flake8 pytest_localftpserver tests 20 | 21 | docs: 22 | name: Build docs 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Python 27 | uses: actions/setup-python@v6 28 | with: 29 | python-version: 3.11 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install -U pip wheel 33 | python -m pip install . -r requirements_dev.txt 34 | - name: Build docs 35 | run: make --directory=docs clean_all html 36 | 37 | docs-links: 38 | name: Check Links in docs 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v2 42 | - name: Set up Python 43 | uses: actions/setup-python@v6 44 | with: 45 | python-version: 3.11 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install -U pip wheel 49 | python -m pip install . -r requirements_dev.txt 50 | - name: Check doc links 51 | continue-on-error: true 52 | run: make --directory=docs clean_all linkcheck 53 | 54 | test: 55 | name: "Tests: py${{ matrix.python-version }} on ${{ matrix.os }}" 56 | runs-on: ${{ matrix.os }} 57 | #needs: [lint, docs] 58 | strategy: 59 | fail-fast: false 60 | matrix: 61 | os: [ubuntu-latest, windows-latest, macOS-latest] 62 | python-version: ["3.11", "3.12", "3.13"] 63 | 64 | steps: 65 | - uses: actions/checkout@v2 66 | - name: Set up Python ${{ matrix.python-version }} 67 | uses: actions/setup-python@v6 68 | with: 69 | python-version: ${{ matrix.python-version }} 70 | - name: Install dependencies 71 | run: | 72 | python -m pip install -U pip wheel 73 | python -m pip install -U coverage coveralls tox "tox-gh-actions>2" 74 | - name: Show installed packages 75 | run: pip freeze 76 | - name: Run tests 77 | run: | 78 | tox 79 | coverage xml 80 | - name: Upload Coverage 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | COVERALLS_FLAG_NAME: tests-${{ matrix.python-version }}-${{ matrix.os }} 84 | COVERALLS_PARALLEL: true 85 | run: coveralls --service=github 86 | 87 | test-custom-config: # this runs the ftp server with modified environment 88 | name: "Tests: Custom config on ${{ matrix.os }}" 89 | runs-on: ${{ matrix.os }} 90 | # needs: [lint, docs] 91 | strategy: 92 | matrix: 93 | os: [ubuntu-latest, windows-latest, macOS-latest] 94 | 95 | steps: 96 | - uses: actions/checkout@v2 97 | - name: Set up Python 98 | uses: actions/setup-python@v6 99 | with: 100 | python-version: 3.11 101 | - name: Install dependencies 102 | run: | 103 | python -m pip install -U pip wheel 104 | python -m pip install -U coverage coveralls tox "tox-gh-actions>2" 105 | - name: Show installed packages 106 | run: pip freeze 107 | - name: Run tests 108 | run: | 109 | tox -e custom_config 110 | coverage xml 111 | - name: Upload Coverage 112 | env: 113 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 114 | COVERALLS_FLAG_NAME: tests-custom_config-${{ matrix.python-version }}-${{ matrix.os }} 115 | COVERALLS_PARALLEL: true 116 | run: coveralls --service=github 117 | 118 | coveralls: 119 | name: Finish Coveralls 120 | needs: [test, test-custom-config] 121 | runs-on: ubuntu-latest 122 | container: python:3-slim 123 | steps: 124 | - name: Finished 125 | run: | 126 | pip3 install -U coveralls 127 | coveralls --finish --service=github 128 | env: 129 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 130 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 131 | 132 | build-n-publish: 133 | name: Build and publish Python 🐍 distributions 📦 to PyPI 134 | runs-on: ubuntu-latest 135 | steps: 136 | - uses: actions/checkout@master 137 | - name: Set up Python 138 | uses: actions/setup-python@v1 139 | with: 140 | python-version: 3.9 141 | - name: Install pypa/build 142 | run: | 143 | python -m pip install build --user 144 | - name: Build a binary wheel and a source tarball 145 | run: >- 146 | python -m 147 | build 148 | --sdist 149 | --wheel 150 | --outdir dist/ 151 | . 152 | - name: Publish distribution 📦 to PyPI 153 | if: startsWith(github.ref, 'refs/tags') 154 | uses: pypa/gh-action-pypi-publish@master 155 | with: 156 | user: __token__ 157 | password: ${{ secrets.PYPI_API_TOKEN }} 158 | 159 | -------------------------------------------------------------------------------- /tests/test_keycert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | xmBVTvI7/nBmfHxDEkZmbRskW0OC1w== 52 | -----END PRIVATE KEY----- 53 | -----BEGIN CERTIFICATE----- 54 | MIIF4jCCA8oCCQDsVGGJ29mDjTANBgkqhkiG9w0BAQsFADCBsjELMAkGA1UEBhMC 55 | QlIxEjAQBgNVBAgMCVNhbyBQYXVsbzESMBAGA1UEBwwJU2FvIFBhdWxvMR4wHAYD 56 | VQQKDBVQeVRlc3QgTG9jYWxGVFBTZXJ2ZXIxFDASBgNVBAsMC0RldmVsb3BtZW50 57 | MR4wHAYDVQQDDBVweXRlc3QubG9jYWxmdHBzZXJ2ZXIxJTAjBgkqhkiG9w0BCQEW 58 | FnB5dGVzdEBsb2NhbGZ0cC5zZXJ2ZXIwHhcNMTkxMjA5MjE0MjQwWhcNMjAxMjA4 59 | MjE0MjQwWjCBsjELMAkGA1UEBhMCQlIxEjAQBgNVBAgMCVNhbyBQYXVsbzESMBAG 60 | A1UEBwwJU2FvIFBhdWxvMR4wHAYDVQQKDBVQeVRlc3QgTG9jYWxGVFBTZXJ2ZXIx 61 | FDASBgNVBAsMC0RldmVsb3BtZW50MR4wHAYDVQQDDBVweXRlc3QubG9jYWxmdHBz 62 | ZXJ2ZXIxJTAjBgkqhkiG9w0BCQEWFnB5dGVzdEBsb2NhbGZ0cC5zZXJ2ZXIwggIi 63 | MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCxVQil3NElH1eve9/CUAfaufTe 64 | T38M2prWsHEtVZ2QHeOIBbv0HLpRxEmIVBEDkl2oJSNqceeW26WU+FwgojQQ5QYq 65 | fETLhuUdLrR37lOx/ghPmUw7Z6Xlfr6IPPy6lFYCOhICip1bKV7Ob97ybHaCwysb 66 | 7KF8BguQEm1IaslOoCreaZmSMXoNbpvSTmdPcq6Bt/XxgxMy+zkDzPd0M3UGabcZ 67 | SU2N/vbs558DB5b4DGWnYC+UMijqNbpvOJILqYNokkVSWdRFLjlJQDsa+v8TOxHG 68 | tRJHXNN15DohI2jX2NOKzEPRZaBEUupiHQ9at6BfvOb4AkfKDumwQGQvNukC/jRZ 69 | BuiB5gKbwcb0fR78ZNUDFqy1d2DgEIzd5egbt2cYQOyuTrW2MPhq6HjUra4R3riU 70 | x1ZSpK5RFLLs5X30mwGF3O/UPQewhTkR9ZhO4kSBp305XP8bWFfb9xs/K8gLilqW 71 | fUddvZRQrAQqyADEs6oVdPj68aXYvdOMgFMMJxPUEFF5s8xuQSXLJUM2SVaiwatu 72 | hgFAiH+Z3EPC4DfzsSwCJ6tT9JzuCk54xzMsRVAQTySiuyRIpTtztbe0MVQP3x9h 73 | YRN8Yrhc/+X4A3BprZtZhc6MRGDG8EZxjA98gPqsKLWqn1GVqQh38pxFKmA/lcx1 74 | fX2C/kEhgHfv+FXfAQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQAL8dx2Ciz1pvTC 75 | V0xxA4aoMygE04Jj+/LeL5pj2bcbZOsieIoREJDR6RfTx37iSmudT86ygyl5iJ/I 76 | Ue/s9mK5n2MqTx7IEAnkx1cDxqMMV+J9gK2gADLjM+IZ6P1c85v3AJy9jev8hVNT 77 | b2UIenIXPN+KD8DdkerWL0hy+FX9CZph9R74UmJ3kmtnY1zpaNCRISVXJtdSMD6l 78 | dWo0UzBxhUJs11ZqPM/o7OFonUabn/yrBBRDmGO3CaEJs+D9u9u3kqpZQBpAvtSw 79 | JhGZ1WahNPnoOP/k+8ndoaX0GhaiPqRT48bN1+hID70TMitdfA3ag0oiYbZLSsHJ 80 | zXpl+pGvhlOiRbcfQquc/Qs916iWeVyDLYPEqgNW6S8ZBa+6fxV4K8SlzGbIID0R 81 | CmzyG0ssYGs+wo+2SU1qqZTzoapcKZ2I/L7eWLKTfKeYV0PTtgVhsJORJg6NrVBF 82 | tPF9aR2wZWGkrYsUjOkdHAUeZrAPtwikcZEXzTlMOCLrEIgUR7tmN4vC+TZhd5wP 83 | Qv5F1ci/MurbZMBievbSk1XquxPUgW4qY/Pj/QOP9ceDoJ8cG9+J7Nnn/MzsFLco 84 | pi159VkWcuCRVMSag7O0kWucAHckIZiH86CQCTRO26t1QIKnSQsqSOmDf+ChAJp7 85 | WuwmHih1fgUfXLws2TUTlEseSpaW3A== 86 | -----END CERTIFICATE----- 87 | -------------------------------------------------------------------------------- /pytest_localftpserver/default_keycert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | 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 51 | xmBVTvI7/nBmfHxDEkZmbRskW0OC1w== 52 | -----END PRIVATE KEY----- 53 | -----BEGIN CERTIFICATE----- 54 | MIIF4jCCA8oCCQDsVGGJ29mDjTANBgkqhkiG9w0BAQsFADCBsjELMAkGA1UEBhMC 55 | QlIxEjAQBgNVBAgMCVNhbyBQYXVsbzESMBAGA1UEBwwJU2FvIFBhdWxvMR4wHAYD 56 | VQQKDBVQeVRlc3QgTG9jYWxGVFBTZXJ2ZXIxFDASBgNVBAsMC0RldmVsb3BtZW50 57 | MR4wHAYDVQQDDBVweXRlc3QubG9jYWxmdHBzZXJ2ZXIxJTAjBgkqhkiG9w0BCQEW 58 | FnB5dGVzdEBsb2NhbGZ0cC5zZXJ2ZXIwHhcNMTkxMjA5MjE0MjQwWhcNMjAxMjA4 59 | MjE0MjQwWjCBsjELMAkGA1UEBhMCQlIxEjAQBgNVBAgMCVNhbyBQYXVsbzESMBAG 60 | A1UEBwwJU2FvIFBhdWxvMR4wHAYDVQQKDBVQeVRlc3QgTG9jYWxGVFBTZXJ2ZXIx 61 | FDASBgNVBAsMC0RldmVsb3BtZW50MR4wHAYDVQQDDBVweXRlc3QubG9jYWxmdHBz 62 | ZXJ2ZXIxJTAjBgkqhkiG9w0BCQEWFnB5dGVzdEBsb2NhbGZ0cC5zZXJ2ZXIwggIi 63 | MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCxVQil3NElH1eve9/CUAfaufTe 64 | T38M2prWsHEtVZ2QHeOIBbv0HLpRxEmIVBEDkl2oJSNqceeW26WU+FwgojQQ5QYq 65 | fETLhuUdLrR37lOx/ghPmUw7Z6Xlfr6IPPy6lFYCOhICip1bKV7Ob97ybHaCwysb 66 | 7KF8BguQEm1IaslOoCreaZmSMXoNbpvSTmdPcq6Bt/XxgxMy+zkDzPd0M3UGabcZ 67 | SU2N/vbs558DB5b4DGWnYC+UMijqNbpvOJILqYNokkVSWdRFLjlJQDsa+v8TOxHG 68 | tRJHXNN15DohI2jX2NOKzEPRZaBEUupiHQ9at6BfvOb4AkfKDumwQGQvNukC/jRZ 69 | BuiB5gKbwcb0fR78ZNUDFqy1d2DgEIzd5egbt2cYQOyuTrW2MPhq6HjUra4R3riU 70 | x1ZSpK5RFLLs5X30mwGF3O/UPQewhTkR9ZhO4kSBp305XP8bWFfb9xs/K8gLilqW 71 | fUddvZRQrAQqyADEs6oVdPj68aXYvdOMgFMMJxPUEFF5s8xuQSXLJUM2SVaiwatu 72 | hgFAiH+Z3EPC4DfzsSwCJ6tT9JzuCk54xzMsRVAQTySiuyRIpTtztbe0MVQP3x9h 73 | YRN8Yrhc/+X4A3BprZtZhc6MRGDG8EZxjA98gPqsKLWqn1GVqQh38pxFKmA/lcx1 74 | fX2C/kEhgHfv+FXfAQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQAL8dx2Ciz1pvTC 75 | V0xxA4aoMygE04Jj+/LeL5pj2bcbZOsieIoREJDR6RfTx37iSmudT86ygyl5iJ/I 76 | Ue/s9mK5n2MqTx7IEAnkx1cDxqMMV+J9gK2gADLjM+IZ6P1c85v3AJy9jev8hVNT 77 | b2UIenIXPN+KD8DdkerWL0hy+FX9CZph9R74UmJ3kmtnY1zpaNCRISVXJtdSMD6l 78 | dWo0UzBxhUJs11ZqPM/o7OFonUabn/yrBBRDmGO3CaEJs+D9u9u3kqpZQBpAvtSw 79 | JhGZ1WahNPnoOP/k+8ndoaX0GhaiPqRT48bN1+hID70TMitdfA3ag0oiYbZLSsHJ 80 | zXpl+pGvhlOiRbcfQquc/Qs916iWeVyDLYPEqgNW6S8ZBa+6fxV4K8SlzGbIID0R 81 | CmzyG0ssYGs+wo+2SU1qqZTzoapcKZ2I/L7eWLKTfKeYV0PTtgVhsJORJg6NrVBF 82 | tPF9aR2wZWGkrYsUjOkdHAUeZrAPtwikcZEXzTlMOCLrEIgUR7tmN4vC+TZhd5wP 83 | Qv5F1ci/MurbZMBievbSk1XquxPUgW4qY/Pj/QOP9ceDoJ8cG9+J7Nnn/MzsFLco 84 | pi159VkWcuCRVMSag7O0kWucAHckIZiH86CQCTRO26t1QIKnSQsqSOmDf+ChAJp7 85 | WuwmHih1fgUfXLws2TUTlEseSpaW3A== 86 | -----END CERTIFICATE----- 87 | -------------------------------------------------------------------------------- /tests/test_pytest_localftpserver_TLS.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | test_pytest_localftpserver 5 | ---------------------------------- 6 | 7 | Tests for `pytest_localftpserver` module. 8 | """ 9 | 10 | import os 11 | 12 | from ftplib import error_perm 13 | import pytest 14 | 15 | 16 | from .test_pytest_localftpserver import (ftp_login, 17 | check_files_by_ftpclient, 18 | close_client, 19 | FILE_LIST) 20 | 21 | from pytest_localftpserver.servers import (SimpleFTPServer, 22 | WrongFixtureError, 23 | DEFAULT_CERTFILE) 24 | 25 | from pytest_localftpserver.helper_functions import InvalidCertificateError 26 | 27 | 28 | def test_is_TLS(ftpserver_TLS): 29 | assert ftpserver_TLS.uses_TLS is True 30 | 31 | 32 | @pytest.mark.parametrize("anon", 33 | [True, False]) 34 | def test_get_login_data(ftpserver_TLS, anon): 35 | login_dict = ftpserver_TLS.get_login_data(style="dict") 36 | assert login_dict["host"] == "localhost" 37 | assert login_dict["port"] == ftpserver_TLS.server_port 38 | if not anon: 39 | assert login_dict["user"] == "fakeusername" 40 | assert login_dict["passwd"] == "qweqwe" 41 | login_url = ftpserver_TLS.get_login_data(style="url", anon=anon) 42 | if anon: 43 | base_url = "ftpes://localhost:" 44 | else: 45 | base_url = "ftpes://fakeusername:qweqwe@localhost:" 46 | assert login_url == base_url + str(ftpserver_TLS.server_port) 47 | 48 | 49 | def test_file_upload_user(ftpserver_TLS, tmpdir): 50 | # makes sure to start with clean temp dirs 51 | ftpserver_TLS.reset_tmp_dirs() 52 | ftp = ftp_login(ftpserver_TLS, use_TLS=True) 53 | ftp.cwd("/") 54 | ftp.mkd("FOO") 55 | ftp.cwd("FOO") 56 | filename = "testfile.txt" 57 | file_path_local = tmpdir.join(filename) 58 | file_path_local.write("test") 59 | with open(str(file_path_local), "rb") as f: 60 | ftp.storbinary("STOR "+filename, f) 61 | close_client(ftp) 62 | 63 | assert os.path.isdir(os.path.join(ftpserver_TLS.server_home, "FOO")) 64 | abs_file_path_server = os.path.join(ftpserver_TLS.server_home, "FOO", 65 | filename) 66 | assert os.path.isfile(abs_file_path_server) 67 | with open(abs_file_path_server) as f: 68 | assert f.read() == "test" 69 | 70 | 71 | def test_file_upload_anon(ftpserver_TLS): 72 | # anon user has no write privileges 73 | ftp = ftp_login(ftpserver_TLS, anon=True, use_TLS=True) 74 | ftp.cwd("/") 75 | with pytest.raises(error_perm): 76 | ftp.mkd("FOO") 77 | close_client(ftp) 78 | 79 | 80 | @pytest.mark.parametrize("anon", 81 | [False, True]) 82 | def test_get_file_paths(tmpdir, ftpserver_TLS, anon): 83 | # makes sure to start with clean temp dirs 84 | ftpserver_TLS.reset_tmp_dirs() 85 | base_path = ftpserver_TLS.get_local_base_path(anon=anon) 86 | files_on_server = [] 87 | for dirs, filename in FILE_LIST: 88 | dir_path = os.path.abspath(os.path.join(base_path, dirs)) 89 | if dirs != "": 90 | os.makedirs(dir_path) 91 | abs_file_path = os.path.join(dir_path, filename) 92 | file_path = "/".join([dirs, filename]).lstrip("/") 93 | files_on_server.append(file_path) 94 | with open(abs_file_path, "a") as f: 95 | f.write(filename) 96 | 97 | path_iterable = list(ftpserver_TLS.get_file_paths(anon=anon)) 98 | assert len(path_iterable) == len(FILE_LIST) 99 | # checking the files by rel_path to user home dir 100 | # and native ftp client 101 | check_files_by_ftpclient(ftpserver_TLS, 102 | tmpdir, 103 | files_on_server, 104 | path_iterable, 105 | anon, 106 | use_TLS=True) 107 | 108 | 109 | @pytest.mark.parametrize("style, read_mode", [ 110 | ("path", "r"), 111 | ("content", "r"), 112 | ("content", "rb") 113 | ]) 114 | def test_ftpserver_TLS_get_cert(ftpserver_TLS, style, read_mode): 115 | result = ftpserver_TLS.get_cert(style=style, read_mode=read_mode) 116 | if style == "path": 117 | assert result == DEFAULT_CERTFILE 118 | else: 119 | with open(DEFAULT_CERTFILE, read_mode) as certfile: 120 | assert result == certfile.read() 121 | 122 | 123 | def test_ftpserver_get_cert_exceptions(ftpserver, ftpserver_TLS): 124 | with pytest.raises( 125 | WrongFixtureError, 126 | match=r"The fixture ftpserver isn't using TLS, and thus" 127 | r"has no certificate. Use ftpserver_TLS instead."): 128 | ftpserver.get_cert() 129 | 130 | # type errors 131 | with pytest.raises( 132 | TypeError, 133 | match="The Argument `style` needs to be of type " 134 | "``str``, the type given type was ``bool``."): 135 | ftpserver.get_cert(style=True) 136 | 137 | with pytest.raises( 138 | TypeError, 139 | match="The Argument `read_mode` needs to be of type " 140 | "``str``, the type given type was ``bool``."): 141 | ftpserver.get_cert(read_mode=True) 142 | 143 | # value errors 144 | with pytest.raises( 145 | ValueError, 146 | match="The Argument `style` needs to be of value " 147 | "'path' or 'content', the given value was 'dict'."): 148 | list(ftpserver.get_cert(style="dict")) 149 | 150 | with pytest.raises( 151 | ValueError, 152 | match="The Argument `read_mode` needs to be of value " 153 | "'r' or 'rb', the given value was 'invalid_option'."): 154 | list(ftpserver.get_cert(read_mode="invalid_option")) 155 | 156 | 157 | def test_wrong_cert_exception(): 158 | wrong_cert = os.path.abspath(os.path.join(os.path.dirname(__file__), 159 | "not_a_valid_cert.pem")) 160 | with pytest.raises(InvalidCertificateError): 161 | SimpleFTPServer(use_TLS=True, certfile=wrong_cert) 162 | -------------------------------------------------------------------------------- /tests/test_helper_functions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from collections.abc import Iterable 4 | import logging 5 | import os 6 | import socket 7 | 8 | import pytest 9 | 10 | from pytest_localftpserver.helper_functions import (get_env_dict, 11 | get_scope, 12 | get_socket, 13 | arg_validator, 14 | pretty_logger, 15 | DEFAULT_CERTFILE) 16 | 17 | 18 | def test_get_env_dict(): 19 | result_dict = {} 20 | result_dict["username"] = "fakeusername" 21 | result_dict["password"] = "qweqwe" 22 | result_dict["ftp_home"] = "" 23 | result_dict["ftp_port"] = 0 24 | result_dict["certfile"] = os.path.abspath(DEFAULT_CERTFILE) 25 | env_dict = get_env_dict() 26 | assert env_dict == result_dict 27 | 28 | 29 | @pytest.mark.parametrize("env_var", 30 | ["function", "module", "session"]) 31 | def test_get_scope(monkeypatch, env_var): 32 | monkeypatch.setenv('FTP_FIXTURE_SCOPE', env_var) 33 | assert get_scope() == env_var 34 | 35 | 36 | def test_get_scope_warn(monkeypatch): 37 | monkeypatch.setenv('FTP_FIXTURE_SCOPE', "not_a_scope") 38 | with pytest.warns( 39 | UserWarning, 40 | match=r"The scope 'not_a_scope', given by the environment " 41 | r"variable 'FTP_FIXTURE_SCOPE' is not a valid scope, " 42 | r"which is why the default scope 'module'was used. " 43 | r"Valid scopes are 'function', 'module' and 'session'."): 44 | get_scope() 45 | 46 | 47 | def test_get_socket(): 48 | socket_obj, taken_port = get_socket() 49 | assert isinstance(socket_obj, socket.socket) 50 | assert isinstance(taken_port, int) 51 | with pytest.warns( 52 | UserWarning, match=r"PYTEST_LOCALFTPSERVER: " 53 | r"The desire port {} was not free, so the " 54 | r"server will run at port \d+.".format(taken_port)): 55 | socket_obj2, new_port = get_socket(taken_port) 56 | assert isinstance(socket_obj2, socket.socket) 57 | assert taken_port != new_port 58 | 59 | 60 | def test_arg_validator(): 61 | func_locals = {"test_str": "str", 62 | "test_bool": True, 63 | "test_int": 7, 64 | "test_multi_type": "path_str"} 65 | valid_var_list = { 66 | "test_str": 67 | {"valid_values": ["str", "another_str"], 68 | "valid_types": [str]}, 69 | "test_bool": 70 | {"valid_types": [bool]}, 71 | "test_int": 72 | {"valid_values": [7], 73 | "valid_types": [int]}, 74 | "test_multi_type": 75 | {"valid_types": [str, list, tuple]}, 76 | "general_iterable": 77 | {"valid_types": [Iterable]} 78 | } 79 | # test valid_var_overwrite 80 | with pytest.raises(TypeError, 81 | match="`valid_var_overwrite` needs to be a dict " 82 | "of form:"): 83 | arg_validator({}, [], "fail") 84 | with pytest.raises(TypeError, 85 | match="`valid_var_overwrite` needs to be a dict " 86 | "of form:"): 87 | arg_validator({}, [], 1) 88 | with pytest.raises(TypeError, 89 | match="`valid_var_overwrite` needs to be a dict " 90 | "of form:"): 91 | arg_validator({}, [], {"arg_name": "not a dict"}) 92 | missing_key_dict = { 93 | "random_key": 94 | {"valid_values and valid_types missing": 0} 95 | } 96 | with pytest.raises(KeyError, 97 | match="`valid_var_overwrite` needs to be a dict " 98 | "of form:"): 99 | arg_validator(func_locals, valid_var_list, missing_key_dict) 100 | arg_validator(func_locals, valid_var_list, 101 | {"not_in_list": {"valid_types": [str]}}) 102 | arg_validator(func_locals, valid_var_list, 103 | {"test_str": {"valid_values": ["str"]}}) 104 | # test type validation strict 105 | error_types = {"test_str": [1, "``str``", "int"], 106 | "test_bool": ["True", "``bool``", "str"], 107 | "test_int": [False, "``int``", "bool"], 108 | "test_multi_type": 109 | [False, "``str``, ``list`` or ``tuple``", "bool"]} 110 | for key, (val, type_str, given_type) in error_types.items(): 111 | func_locals_wrong_type = dict(func_locals) 112 | func_locals_wrong_type[key] = val 113 | error_msg = "The Argument `{}` needs to be " \ 114 | "of type {}, the type given type was " \ 115 | "``{}``.".format(key, type_str, given_type) 116 | with pytest.raises(TypeError, match=error_msg): 117 | arg_validator(func_locals_wrong_type, valid_var_list) 118 | 119 | # test type validation not strict 120 | def test_generator_func(): 121 | yield from [1, 2] 122 | 123 | test_generator = test_generator_func() 124 | 125 | error_types = [["test_int", False], 126 | ["general_iterable", "str"], 127 | ["general_iterable", [1, 2]], 128 | ["general_iterable", {"key", "val"}], 129 | ["general_iterable", test_generator]] 130 | func_locals_wrong_type = dict(func_locals) 131 | for key, val in error_types: 132 | func_locals_wrong_type[key] = val 133 | arg_validator(func_locals_wrong_type, valid_var_list, 134 | valid_var_overwrite={"test_int": {"valid_values": [0]}}, 135 | strict_type_check=False) 136 | 137 | # test value validation 138 | error_values = {"test_str": 139 | ["wrong value", "'str' or 'another_str'", "'wrong value'"], 140 | "test_int": [1, "`7`", "`1`"]} 141 | for key, (val, value_string, given_value) in error_values.items(): 142 | func_locals_wrong_value = dict(func_locals) 143 | func_locals_wrong_value[key] = val 144 | error_msg = "The Argument `{}` needs to be of value " \ 145 | "{}, the given value was " \ 146 | "{}.".format(key, value_string, given_value) 147 | with pytest.raises(ValueError, match=error_msg): 148 | arg_validator(func_locals_wrong_value, valid_var_list) 149 | # test missing missing entry warning 150 | func_locals_not_def_arg = dict(func_locals) 151 | func_locals_not_def_arg["new_arg"] = "this arg misses in valid_var_list" 152 | with pytest.warns(UserWarning, match=r"new_arg"): 153 | arg_validator(func_locals_not_def_arg, valid_var_list, 154 | implementation_func_name="test_func", dev_mode=True) 155 | 156 | # little cheat for coverage.io ;) 157 | list(test_generator) 158 | 159 | 160 | def test_pretty_logger(caplog): 161 | with caplog.at_level(logging.INFO): 162 | heading = "test_heading" 163 | msg = "{}".format("test_msg") 164 | caplog.clear() 165 | pretty_logger(heading, msg) 166 | log_output = "\n" \ 167 | "##################################################\n" \ 168 | "# test_heading #\n" \ 169 | "##################################################\n" \ 170 | "\n\n" \ 171 | "test_msg\n\n" 172 | 173 | assert [log_output] == [rec.message for rec in caplog.records] 174 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # pytest_localftpserver documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 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 sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another 19 | # directory, add these directories to sys.path here. If the directory is 20 | # relative to the documentation root, use os.path.abspath to make it 21 | # absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # Get the project root dir, which is the parent dir of this 25 | cwd = os.getcwd() 26 | project_root = os.path.dirname(cwd) 27 | 28 | # Insert the project root dir as the first element in the PYTHONPATH. 29 | # This lets us ensure that the source package is imported, and that its 30 | # version is used. 31 | sys.path.insert(0, project_root) 32 | 33 | import pytest_localftpserver 34 | 35 | # -- General configuration --------------------------------------------- 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | #needs_sphinx = '1.0' 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 42 | extensions = ['sphinx.ext.autodoc', 43 | 'sphinx.ext.autosummary', 44 | 'sphinx.ext.viewcode', 45 | 'sphinx.ext.napoleon', 46 | 'sphinx_copybutton'] 47 | 48 | autoclass_content = "both" 49 | autosummary_generate = True 50 | add_module_names = False 51 | autodoc_member_order = "bysource" 52 | numpydoc_show_class_members = False 53 | numpydoc_class_members_toctree = False 54 | 55 | # Add any paths that contain templates here, relative to this directory. 56 | templates_path = ['_templates'] 57 | 58 | # The suffix of source filenames. 59 | source_suffix = '.rst' 60 | 61 | # The encoding of source files. 62 | #source_encoding = 'utf-8-sig' 63 | 64 | # The master toctree document. 65 | master_doc = 'index' 66 | 67 | # General information about the project. 68 | project = 'PyTest local FTP Server' 69 | copyright = "2016, Oz Tiram" 70 | 71 | # The version info for the project you're documenting, acts as replacement 72 | # for |version| and |release|, also used in various other places throughout 73 | # the built documents. 74 | # 75 | # The short X.Y version. 76 | version = pytest_localftpserver.__version__ 77 | # The full version, including alpha/beta/rc tags. 78 | release = pytest_localftpserver.__version__ 79 | 80 | # The language for content autogenerated by Sphinx. Refer to documentation 81 | # for a list of supported languages. 82 | #language = None 83 | 84 | # There are two options for replacing |today|: either, you set today to 85 | # some non-false value, then it is used: 86 | #today = '' 87 | # Else, today_fmt is used as the format for a strftime call. 88 | #today_fmt = '%B %d, %Y' 89 | 90 | # List of patterns, relative to source directory, that match files and 91 | # directories to ignore when looking for source files. 92 | exclude_patterns = ['_build'] 93 | 94 | # The reST default role (used for this markup: `text`) to use for all 95 | # documents. 96 | #default_role = None 97 | 98 | # If true, '()' will be appended to :func: etc. cross-reference text. 99 | #add_function_parentheses = True 100 | 101 | # If true, the current module name will be prepended to all description 102 | # unit titles (such as .. function::). 103 | #add_module_names = True 104 | 105 | # If true, sectionauthor and moduleauthor directives will be shown in the 106 | # output. They are ignored by default. 107 | #show_authors = False 108 | 109 | # The name of the Pygments (syntax highlighting) style to use. 110 | pygments_style = 'sphinx' 111 | 112 | # A list of ignored prefixes for module index sorting. 113 | #modindex_common_prefix = [] 114 | 115 | # If true, keep warnings as "system message" paragraphs in the built 116 | # documents. 117 | #keep_warnings = False 118 | 119 | linkcheck_ignore = [ 120 | r"https://github\.com/oz123/pytest-localftpserver/actions", 121 | r"https://github\.com/oz123/pytest-localftpserver/workflows", 122 | ] 123 | 124 | 125 | # -- Options for HTML output ------------------------------------------- 126 | 127 | # The theme to use for HTML and HTML Help pages. See the documentation for 128 | # a list of builtin themes. 129 | html_theme = 'sphinx_rtd_theme' 130 | 131 | # CopyButton configuration 132 | copybutton_prompt_text = r">>> |\.\.\. |\$ " 133 | copybutton_prompt_is_regexp = True 134 | 135 | # Theme options are theme-specific and customize the look and feel of a 136 | # theme further. For a list of options available for each theme, see the 137 | # documentation. 138 | #html_theme_options = {} 139 | 140 | # Add any paths that contain custom themes here, relative to this directory. 141 | #html_theme_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 148 | # html_title. 149 | #html_short_title = None 150 | 151 | # The name of an image file (relative to this directory) to place at the 152 | # top of the sidebar. 153 | #html_logo = None 154 | 155 | # The name of an image file (within the static path) to use as favicon 156 | # of the docs. This file should be a Windows icon file (.ico) being 157 | # 16x16 or 32x32 pixels large. 158 | #html_favicon = None 159 | 160 | # Add any paths that contain custom static files (such as style sheets) 161 | # here, relative to this directory. They are copied after the builtin 162 | # static files, so a file named "default.css" will overwrite the builtin 163 | # "default.css". 164 | #html_static_path = ['_static'] 165 | 166 | # If not '', a 'Last updated on:' timestamp is inserted at every page 167 | # bottom, using the given strftime format. 168 | #html_last_updated_fmt = '%b %d, %Y' 169 | 170 | # If true, SmartyPants will be used to convert quotes and dashes to 171 | # typographically correct entities. 172 | #html_use_smartypants = True 173 | 174 | # Custom sidebar templates, maps document names to template names. 175 | #html_sidebars = {} 176 | 177 | # Additional templates that should be rendered to pages, maps page names 178 | # to template names. 179 | #html_additional_pages = {} 180 | 181 | # If false, no module index is generated. 182 | #html_domain_indices = True 183 | 184 | # If false, no index is generated. 185 | #html_use_index = True 186 | 187 | # If true, the index is split into individual pages for each letter. 188 | #html_split_index = False 189 | 190 | # If true, links to the reST sources are added to the pages. 191 | #html_show_sourcelink = True 192 | 193 | # If true, "Created using Sphinx" is shown in the HTML footer. 194 | # Default is True. 195 | #html_show_sphinx = True 196 | 197 | # If true, "(C) Copyright ..." is shown in the HTML footer. 198 | # Default is True. 199 | #html_show_copyright = True 200 | 201 | # If true, an OpenSearch description file will be output, and all pages 202 | # will contain a tag referring to it. The value of this option 203 | # must be the base URL from which the finished HTML is served. 204 | #html_use_opensearch = '' 205 | 206 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 207 | #html_file_suffix = None 208 | 209 | # Output file base name for HTML help builder. 210 | htmlhelp_basename = 'pytest_localftpserverdoc' 211 | 212 | 213 | # -- Options for LaTeX output ------------------------------------------ 214 | 215 | latex_elements = { 216 | # The paper size ('letterpaper' or 'a4paper'). 217 | #'papersize': 'letterpaper', 218 | 219 | # The font size ('10pt', '11pt' or '12pt'). 220 | #'pointsize': '10pt', 221 | 222 | # Additional stuff for the LaTeX preamble. 223 | #'preamble': '', 224 | } 225 | 226 | # Grouping the document tree into LaTeX files. List of tuples 227 | # (source start file, target name, title, author, documentclass 228 | # [howto/manual]). 229 | latex_documents = [ 230 | ('index', 'pytest_localftpserver.tex', 231 | 'PyTest local FTP Server Documentation', 232 | 'Oz Tiram', 'manual'), 233 | ] 234 | 235 | # The name of an image file (relative to this directory) to place at 236 | # the top of the title page. 237 | #latex_logo = None 238 | 239 | # For "manual" documents, if this is true, then toplevel headings 240 | # are parts, not chapters. 241 | #latex_use_parts = False 242 | 243 | # If true, show page references after internal links. 244 | #latex_show_pagerefs = False 245 | 246 | # If true, show URL addresses after external links. 247 | #latex_show_urls = False 248 | 249 | # Documents to append as an appendix to all manuals. 250 | #latex_appendices = [] 251 | 252 | # If false, no module index is generated. 253 | #latex_domain_indices = True 254 | 255 | 256 | # -- Options for manual page output ------------------------------------ 257 | 258 | # One entry per manual page. List of tuples 259 | # (source start file, name, description, authors, manual section). 260 | man_pages = [ 261 | ('index', 'pytest_localftpserver', 262 | 'PyTest local FTP Server Documentation', 263 | ['Oz Tiram'], 1) 264 | ] 265 | 266 | # If true, show URL addresses after external links. 267 | #man_show_urls = False 268 | 269 | 270 | # -- Options for Texinfo output ---------------------------------------- 271 | 272 | # Grouping the document tree into Texinfo files. List of tuples 273 | # (source start file, target name, title, author, 274 | # dir menu entry, description, category) 275 | texinfo_documents = [ 276 | ('index', 'pytest_localftpserver', 277 | 'PyTest local FTP Server Documentation', 278 | 'Oz Tiram', 279 | 'pytest_localftpserver', 280 | 'One line description of project.', 281 | 'Miscellaneous'), 282 | ] 283 | 284 | # Documents to append as an appendix to all manuals. 285 | #texinfo_appendices = [] 286 | 287 | # If false, no module index is generated. 288 | #texinfo_domain_indices = True 289 | 290 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 291 | #texinfo_show_urls = 'footnote' 292 | 293 | # If true, do not generate a @detailmenu in the "Top" node's menu. 294 | #texinfo_no_detailmenu = False 295 | -------------------------------------------------------------------------------- /pytest_localftpserver/helper_functions.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | import logging 3 | import os 4 | import socket 5 | import sys 6 | from traceback import print_tb 7 | import warnings 8 | 9 | from ssl import SSLContext, SSLError 10 | from ssl import PROTOCOL_TLS_CLIENT, TLSVersion 11 | 12 | DEFAULT_CERTFILE = os.path.join(os.path.dirname(__file__), 13 | "default_keycert.pem") 14 | 15 | 16 | class InvalidCertificateError(Exception): 17 | pass 18 | 19 | 20 | def get_env_dict(use_TLS=False): 21 | """ 22 | Retrieves the environment variables used to configure 23 | the ftpserver fixtures 24 | 25 | Returns 26 | ------- 27 | env_dict: dict 28 | Dict containing the environment variables used to configure 29 | the ftp fixtures or its default values. 30 | """ 31 | env_dict = {} 32 | env_dict["username"] = os.getenv("FTP_USER", "fakeusername") 33 | env_dict["password"] = os.getenv("FTP_PASS", "qweqwe") 34 | if use_TLS: 35 | env_dict["ftp_home"] = os.getenv("FTP_HOME_TLS", "") 36 | env_dict["ftp_port"] = int(os.getenv("FTP_PORT_TLS", 0)) 37 | else: 38 | env_dict["ftp_home"] = os.getenv("FTP_HOME", "") 39 | env_dict["ftp_port"] = int(os.getenv("FTP_PORT", 0)) 40 | 41 | env_dict["certfile"] = os.path.abspath(os.getenv("FTP_CERTFILE", 42 | DEFAULT_CERTFILE)) 43 | return env_dict 44 | 45 | 46 | def get_scope(): 47 | """ 48 | Retrieves the environment variables used to configure 49 | the ftpserver fixtures 50 | 51 | Returns 52 | ------- 53 | scope: {'function', 'module', 'session'}: default 'module' 54 | Scope at which the fixture should be. 55 | 56 | """ 57 | scope = os.getenv("FTP_FIXTURE_SCOPE", "module") 58 | if scope not in ["function", "module", "session"]: 59 | warnings.warn("The scope '{}', given by the environment variable 'FTP_FIXTURE_SCOPE' " 60 | "is not a valid scope, which is why the default scope 'module'was used. " 61 | "Valid scopes are 'function', 'module' and 'session'.".format(scope), 62 | UserWarning) 63 | scope = "module" 64 | return scope 65 | 66 | 67 | def validate_cert_file(cert_file): 68 | """ 69 | 70 | Parameters 71 | ---------- 72 | cert_file: str 73 | Path to the certfile to be checked. 74 | 75 | Raises 76 | ------ 77 | 78 | InvalidCertificateError 79 | If the certificate is not valid. 80 | 81 | """ 82 | cert_file = os.path.abspath(cert_file) 83 | try: 84 | context = SSLContext(PROTOCOL_TLS_CLIENT) 85 | context.minimum_version = TLSVersion.TLSv1_2 86 | context.maximum_version = TLSVersion.TLSv1_3 87 | context.load_cert_chain(cert_file) 88 | except SSLError as e: 89 | raise InvalidCertificateError("The certificate {}, you tried to use is not valid. " 90 | "Please make sure to use a working certificate or " 91 | "leave it unconfigured to use the default certificate. " 92 | "Details: {}" 93 | "".format(cert_file, e)) 94 | 95 | 96 | def get_socket(desired_port=0): 97 | """ 98 | 99 | Parameters 100 | ---------- 101 | desired_port: int 102 | Port which is desired to be used 103 | 104 | Returns 105 | ------- 106 | (socket, port): tuple 107 | Serveraddress as tuple '(socket, port)' 108 | 109 | """ 110 | free_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 111 | try: 112 | free_socket.bind(("", desired_port)) 113 | except Exception: 114 | # Create a socket on any free port, if desired_port is taken 115 | free_socket.bind(("", 0)) 116 | _, free_port = free_socket.getsockname() 117 | if desired_port != 0 and desired_port != free_port: 118 | warnings.warn("PYTEST_LOCALFTPSERVER: The desire port {} was not free, so the " 119 | "server will run at port {}.".format(desired_port, free_port), 120 | UserWarning) 121 | return free_socket, free_port 122 | 123 | 124 | def pretty_logger(heading, msg): 125 | """ 126 | Helper function to pretty log function output for debug purposes 127 | 128 | Parameters 129 | ---------- 130 | heading: str 131 | Heading of the section which should be logged 132 | msg: str 133 | Message to be logged 134 | """ 135 | heading_width = 50 136 | decoration_str = "\n" + "#"*heading_width + "\n" 137 | heading = "#" + heading.center(heading_width-2) + "#" 138 | heading = "{decoration_str}{heading}{decoration_str}\n\n".format(decoration_str=decoration_str, 139 | heading=heading) 140 | logging.info(heading+msg+"\n"*2) 141 | 142 | 143 | def arg_validator_excepthook(exc_type, exc_value, exc_traceback): 144 | """ 145 | This is a helper function for using `arg_validator`, which reduces the traceback 146 | to the calling function and the raised error_type and error_msg. 147 | This is done by overwriting `sys.excepthook` in case of an `Exception` caused by 148 | `arg_validator`(see Examples). 149 | The Parameter are the same as the output of `sys.exc_info()` 150 | 151 | Parameters 152 | ---------- 153 | exc_type: type 154 | Exception type 155 | exc_value: str 156 | Exception message 157 | exc_traceback: traceback 158 | Traceback of the Exception 159 | 160 | Examples 161 | -------- 162 | 163 | >>>import sys 164 | >>>from sys import excepthook as _excepthook 165 | >>>try: 166 | ... sys.excepthook = _excepthook 167 | ... arg_validator(func_locals, valid_var_list) 168 | ...except (ValueError, TypeError) as e: 169 | ... sys.excepthook = arg_validator_excepthook 170 | ... raise e 171 | """ 172 | # since the excepthook will be caught by pytest there is no reason to 173 | # run it trought coverage 174 | print_tb(exc_traceback, limit=1, file=sys.stderr) # pragma: no cover 175 | print(f"{exc_type.__name__}: {exc_value}", file=sys.stderr) # pragma: no cover 176 | 177 | 178 | def arg_validator(func_locals, valid_var_dict, valid_var_overwrite=None, 179 | implementation_func_name="_option_validator", 180 | strict_type_check=True, dev_mode=False): 181 | """ 182 | Development helperfunction to raise appropriate Error if a methods/functions 183 | arg/kwarg is of wrong type or value. 184 | There are two ways of usage: 185 | One is to call the function directly after a 186 | function/Class/method is invoked: 187 | 188 | .. code:: python 189 | def func_to_test(argument_name, *args, **kwargs): 190 | func_locals = locals() 191 | valid_var_list = [{'name': 'argument_name', 192 | 'valid_values':['test', {'test': 'testval'}], 193 | 'valid_types':[str, dict]}] 194 | 195 | self.arg_validator(func_locals, valid_var_list) 196 | 197 | 198 | The other is to embed the function in a decorator: 199 | 200 | 201 | .. code:: python 202 | 203 | def arg_validator_decorator(f): 204 | 205 | valid_var_list = [{'name': 'argument_name', 206 | 'valid_values':['test', {'test': 'testval'}], 207 | 'valid_types':[str, dict]}] 208 | 209 | def wrapper(*args, **kwargs) 210 | func_locals = dict(**dict(zip(f.__code__.co_varnames[1:], args))) 211 | func_locals.update(kwargs) 212 | arg_validator(func_locals, valid_var_list) 213 | 214 | return f(*args, **kwargs) 215 | 216 | return wrapper 217 | 218 | @arg_validator_decorator 219 | def func_to_test(argument_name, *args, **kwargs): 220 | 221 | 222 | If a arg/kwarg isn't in the default values defined in `valid_var_list` 223 | add it to `valid_var_list` or use the option `valid_var_overwrite`, 224 | if it varies just in that case. 225 | 226 | 227 | Parameters 228 | ---------- 229 | func_locals: dict 230 | The result of calling ``locals()`` at the very beginning of a method. 231 | If ``locals()`` is called later this might lead to problems with locally 232 | defined variables 233 | 234 | valid_var_dict: dict 235 | Dict of valid variables with the variable name as key and the value being 236 | a dict with keys 'valid_values'/'valid_types' which values are sequences: 237 | {'argument_name', 238 | 'valid_values':['test', {'test': 'testval'}], 239 | 'valid_types':[str, dict] 240 | } 241 | 242 | valid_var_overwrite: dict: default None 243 | This is used if in a special case, an args/kwargs value/type varies 244 | from the one defines in `valid_var_dict` 245 | 246 | Raises 247 | ------ 248 | TypeError 249 | If any of the checked args/kwargs has a not supported type 250 | 251 | ValueError 252 | If any of the checked args/kwargs has a not supported value 253 | """ 254 | # copy list by value (list1 = list2 would be copy by reference) 255 | valid_var_dict = deepcopy(valid_var_dict) 256 | # check if `valid_var_overwrite` has a valid form 257 | if valid_var_overwrite: 258 | error_msg = "`valid_var_overwrite` needs to be a dict of form:\n" \ 259 | "{'argument_name':\n" \ 260 | " {'valid_values':['test', {'test': 'testval'}],\n" \ 261 | " 'valid_types':[str, dict]}\n" \ 262 | "}" 263 | if not isinstance(valid_var_overwrite, dict): 264 | raise TypeError(error_msg) 265 | 266 | for _, value_dict in valid_var_overwrite.items(): 267 | if not isinstance(value_dict, dict): 268 | raise TypeError(error_msg) 269 | 270 | elif all([key not in value_dict.keys() for key in ["valid_values", "valid_types"]]): 271 | raise KeyError(error_msg) 272 | 273 | # update overwrites 274 | valid_var_dict.update(valid_var_overwrite) 275 | for key, val in func_locals.items(): 276 | found_entry = False 277 | if key in valid_var_dict.keys(): 278 | validate_dict = valid_var_dict[key] 279 | found_entry = True 280 | msg_dict = {"key": key, "val_type": type(val).__name__} 281 | if "valid_types" in validate_dict and len(validate_dict["valid_types"]): 282 | if strict_type_check: 283 | # here ``type(val) in validate_dict["valid_types"]`` is used instead of 284 | # isinstance(val, tuple(validate_dict["valid_types"])) because of false 285 | # positives for isinstance(True, int), which may cause problems 286 | invalid_type = type(val) not in validate_dict["valid_types"] 287 | else: 288 | invalid_type = not isinstance(val, tuple(validate_dict["valid_types"])) 289 | if invalid_type: 290 | valid_types = validate_dict["valid_types"] 291 | if len(valid_types) == 1: 292 | msg_dict["type_string"] = "``{}``" \ 293 | "".format(valid_types[0].__name__) 294 | else: 295 | valid_type_list = [f"``{valid_type.__name__}``" 296 | for valid_type in valid_types[:-1]] 297 | base_str = ", ".join(valid_type_list) 298 | msg_dict["type_string"] = "{} or ``{}``" \ 299 | "".format(base_str, 300 | valid_types[-1].__name__) 301 | raise TypeError("The Argument `{key}` needs to be of type " 302 | "{type_string}, the type given type was " 303 | "``{val_type}``.".format(**msg_dict)) 304 | 305 | has_valid_values = "valid_values" in validate_dict and \ 306 | len(validate_dict["valid_values"]) 307 | if has_valid_values and val not in validate_dict["valid_values"]: 308 | 309 | valid_values = validate_dict["valid_values"] 310 | 311 | if isinstance(val, str): 312 | formater_str = "'{}'" 313 | else: 314 | formater_str = "`{}`" 315 | if len(valid_values) == 1: 316 | msg_dict["value_string"] = formater_str.format(valid_values[0]) 317 | else: 318 | valid_values_list = [formater_str.format(valid_value) 319 | for valid_value in valid_values[:-1]] 320 | base_str = ", ".join(valid_values_list) 321 | last_val_str = formater_str.format(valid_values[-1]) 322 | msg_dict["value_string"] = "{} or {}".format(base_str, 323 | last_val_str) 324 | msg_dict["val"] = formater_str.format(val) 325 | 326 | raise ValueError("The Argument `{key}` needs to be of value " 327 | "{value_string}, the given value was " 328 | "{val}.".format(**msg_dict)) 329 | # this is a convenience functionality, for implementing new functions 330 | if not found_entry and dev_mode and key != "self": 331 | warn_msg = "`valid_var_list` in `{}` is missing an entry, " \ 332 | "where entry['name']=={}, this entry won't be " \ 333 | "validated.".format(implementation_func_name, key) 334 | warnings.warn(warn_msg, UserWarning) 335 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | After installing `pytest_localftpserver` the fixture ``ftpserver`` is available for 6 | your pytest test functions. Note that you can't use fixtures outside of functions and 7 | need to pass them as arguments. 8 | 9 | Basic usage 10 | =========== 11 | 12 | A basic example of using `pytest_localftpserver` would be, if you wanted to test code, 13 | which uploads a file to a FTP-server. 14 | 15 | .. code-block:: python 16 | 17 | import os 18 | 19 | 20 | def test_your_code_to_upload_files(ftpserver): 21 | your_code_to_upload_files(host="localhost", 22 | port=ftpserver.server_port, 23 | username=ftpserver.username, 24 | password=ftpserver.password, 25 | files=["testfile.txt"]) 26 | 27 | uploaded_file_path = os.path.join(ftpserver.server_home, "testfile.txt") 28 | with open("testfile.txt") as original, open(uploaded_file_path) as uploaded: 29 | assert original.read() == uploaded.read() 30 | 31 | .. note:: Like most public FTP-servers `pytest_localftpserver` doesn't allow the anonymous 32 | user to upload files. The anonymous user is only allowed to browse the folder structure 33 | and download files. If you want to upload files you need to use the registered user, 34 | with its password. 35 | 36 | An other common use case would be retrieving a file from a FTP-server. 37 | 38 | .. code-block:: python 39 | 40 | import os 41 | from shutil import copyfile 42 | 43 | 44 | def test_your_code_retrieving_files(ftpserver): 45 | dest_path = os.path.join(ftpserver.anon_root, "testfile.txt") 46 | copyfile("testfile.txt", dest_path) 47 | your_code_retrieving_files(host="localhost", 48 | port=ftpserver.server_port 49 | file_paths=[{"remote": "testfile.txt", 50 | "local": "testfile_downloaded.txt" 51 | }]) 52 | with open("testfile.txt") as original, open("testfile_downloaded.txt") as downloaded: 53 | assert original.read() == downloaded.read() 54 | 55 | 56 | Login with the TLS server 57 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 58 | 59 | This example utilizes methods of the the high-level interface, which are explained in 60 | :ref:`get_login_data` and :ref:`get_file_contents`. 61 | 62 | The below example test logs into the TLS ftpserver, creates the file ``testfile.txt``, with content 'test text' and 63 | checks if it was written properly. 64 | 65 | .. code-block:: python 66 | 67 | from ftplib import FTP_TLS 68 | 69 | from ssl import SSLContext 70 | try: 71 | from ssl import PROTOCOL_TLS_CLIENT, TLSVersion 72 | except Exception: 73 | from ssl import PROTOCOL_SSLv23 as PROTOCOL_TLS 74 | 75 | 76 | def test_TLS_login(ftpserver_TLS): 77 | if PYTHON3: 78 | ssl_context = SSLContext(PROTOCOL_TLS_CLIENT) 79 | ssl_context.minimum_version = TLSVersion.TLSv1_2 80 | ssl_context.maximum_version = TLSVersion.TLSv1_3 81 | ssl_context.load_cert_chain(certfile=DEFAULT_CERTFILE) 82 | ftp = FTP_TLS(context=ssl_context) 83 | else: 84 | ftp = FTP_TLS(certfile=DEFAULT_CERTFILE) 85 | 86 | login_dict = ftpserver_TLS.get_login_data() 87 | ftp.connect(login_dict["host"], login_dict["port"]) 88 | ftp.login(login_dict["user"], login_dict["passwd"]) 89 | ftp.prot_p() 90 | ftp.cwd("/") 91 | filename = "testfile.txt" 92 | file_path_local = tmpdir.join(filename) 93 | file_path_local.write("test text") 94 | with open(str(file_path_local), "rb") as f: 95 | ftp.storbinary("STOR "+filename, f) 96 | ftp.quit() 97 | file_list = list(ftpserver_TLS.get_file_contents() 98 | assert file_list == [{"path": "testfile.txt", "content": "test text"}] 99 | 100 | 101 | High-Level Interface 102 | ==================== 103 | 104 | To allow you a faster and more comfortable handling of common ftp tasks a high-level 105 | interface was implemented. Most of the following methods have the keyword ``anon``, which 106 | allows to switch between the registered (`anon=False`) and the anonymous (`anon=True`) user. 107 | For more information on how those methods work, take a look at the :ref:`API Documentation` . 108 | 109 | .. note:: The following examples aren't working code, since the aren't called from 110 | within a function, which means that the ``ftpserver`` fixture isn't available. 111 | They are thought to be a quick overview of the available functionality and 112 | its output. 113 | 114 | .. _get_login_data: 115 | 116 | Getting login credentials 117 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 118 | 119 | To quickly get all needed login data you can use ``get_login_data``, which will either return 120 | a dict or an url to log into the ftp:: 121 | 122 | >>> ftpserver.get_login_data() 123 | {"host": "localhost", "port": 8888, "user": "fakeusername", "passwd": "qweqwe"} 124 | 125 | >>> ftpserver.get_login_data(style="url", anon=False) 126 | ftp://fakeusername:qweqwe@localhost:8888 127 | 128 | >>> ftpserver.get_login_data(style="url", anon=True) 129 | ftp://localhost:8888 130 | 131 | 132 | Populating the FTP server with files and folders 133 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 134 | 135 | To test ftp download capabilities of your code, you might want to populate the files on the server. 136 | To "upload" files to the server you can use the method ``put_files``:: 137 | 138 | 139 | >>> ftpserver.put_files("test_folder/test_file", style="rel_path", anon=False) 140 | ["test_file"] 141 | 142 | >>> ftpserver.put_files("test_folder/test_file", style="url", anon=False) 143 | ["ftp://fakeusername:qweqwe@localhost:8888/test_file"] 144 | 145 | >>> ftpserver.put_files("test_folder/test_file", style="url", anon=True) 146 | ["ftp://localhost:8888/test_file"] 147 | 148 | >>> ftpserver.put_files({"src": "test_folder/test_file", 149 | ... "dest": "remote_folder/uploaded_file"}, 150 | ... style="url", anon=True) 151 | ["ftp://localhost:8888/remote_folder/uploaded_file"] 152 | 153 | >>> ftpserver.put_files("test_folder/test_file", return_content=True) 154 | [{"path": "test_file", "content": "some text in test_file"}] 155 | 156 | >>> ftpserver.put_files("test_file.zip", return_content=True, read_mode="rb") 157 | [{"path": "test_file.zip", "content": b'PK\\x03\\x04\\x14\\x00\\x00...'}] 158 | 159 | >>> ftpserver.put_files("test_file", return_paths="new") 160 | UserWarning: test_file does already exist and won't be overwritten. 161 | Set `overwrite` to True to overwrite it anyway. 162 | [] 163 | 164 | >>> ftpserver.put_files("test_file", return_paths="new", overwrite=True) 165 | ["test_file"] 166 | 167 | >>> ftpserver.put_files("test_file3", return_paths="all") 168 | ["test_file", "remote_folder/uploaded_file", "test_file.zip"] 169 | 170 | Resetting files on the server 171 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 172 | 173 | Since ``ftpserver`` is a module scope fixture, you might want to make sure that uploaded files 174 | get deleted after/before a test. This can be done by using the method ``reset_tmp_dirs``. 175 | 176 | `filesystem before`: 177 | 178 | .. code:: bash 179 | 180 | +---server_home 181 | | +---test_file1 182 | | +---test_folder 183 | | +---test_file2 184 | | 185 | +---anon_root 186 | +---test_file3 187 | +---test_folder 188 | +---test_file4 189 | 190 | .. code:: python 191 | 192 | >>> ftpserver.reset_tmp_dirs() 193 | 194 | `filesystem after`: 195 | 196 | .. code:: bash 197 | 198 | +---server_home 199 | | 200 | +---anon_root 201 | 202 | Gaining information on which files are on the server 203 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 204 | 205 | If you want to know which files are on the server, i.e. if you want to know if your 206 | file upload functionality is working, you can use the ``get_file_paths`` method, which will 207 | yield the paths to all files on the server. 208 | 209 | .. code:: bash 210 | 211 | filesystem 212 | +---server_home 213 | | +---test_file1 214 | | +---test_folder 215 | | +---test_file2 216 | | 217 | +---anon_root 218 | +---test_file3 219 | +---test_folder 220 | +---test_file4 221 | 222 | .. code:: python 223 | 224 | >>> list(ftpserver.get_file_paths(style="rel_path", anon=False)) 225 | ["test_file1", "test_folder/test_file2"] 226 | 227 | >>> list(ftpserver.get_file_paths(style="rel_path", anon=True)) 228 | ["test_file3", "test_folder/test_file4"] 229 | 230 | .. _get_file_contents: 231 | 232 | Gaining information about the content of files on the server 233 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 234 | 235 | If you are interested in the content of a specific file, multiple files or all files, 236 | i.e. to verify that your file upload functionality did work properly, you can use the 237 | ``get_file_contents`` method. 238 | 239 | .. code:: bash 240 | 241 | filesystem 242 | +---server_home 243 | +---test_file1.txt 244 | +---test_folder 245 | +---test_file2.zip 246 | 247 | 248 | .. code:: python 249 | 250 | >>> list(ftpserver.get_file_contents()) 251 | [{"path": "test_file1.txt", "content": "test text"}, 252 | {"path": "test_folder/test_file2.txt", "content": "test text2"}] 253 | 254 | >>> list(ftpserver.get_file_contents("test_file1.txt")) 255 | [{"path": "test_file1.txt", "content": "test text"}] 256 | 257 | >>> list(ftpserver.get_file_contents("test_file1.txt", style="url")) 258 | [{"path": "ftp://fakeusername:qweqwe@localhost:8888/test_file1.txt", 259 | "content": "test text"}] 260 | 261 | >>> list(ftpserver.get_file_contents(["test_file1.txt", "test_folder/test_file2.zip"], 262 | ... read_mode="rb")) 263 | [{"path": "test_file1.txt", "content": b"test text"}, 264 | {"path": "test_folder/test_file2.zip", "content": b'PK\\x03\\x04\\x14\\x00\\x00...'}] 265 | 266 | 267 | 268 | Configuration 269 | ============= 270 | 271 | To configure custom values for for the username, the users password, the ftp port and/or 272 | the location of the users home folder on the local storage, you need to set the environment 273 | variables ``FTP_USER``, ``FTP_PASS``, ``FTP_PORT``, ``FTP_HOME``, ``FTP_FIXTURE_SCOPE``, 274 | ``FTP_PORT_TLS``, ``FTP_HOME_TLS`` and ``FTP_CERTFILE``. 275 | 276 | 277 | ===================== ============================================================================= 278 | Environment variable Usage 279 | ===================== ============================================================================= 280 | ``FTP_USER`` Username of the registered user. 281 | ``FTP_PASS`` Password of the registered user. 282 | ``FTP_PORT`` Port for the normal ftp server to run on. 283 | ``FTP_HOME`` Home folder (host system) of the registered user. 284 | ``FTP_FIXTURE_SCOPE`` Scope/lifetime of the fixture. 285 | ``FTP_PORT_TLS`` Port for the TLS ftp server to run on. 286 | ``FTP_HOME_TLS`` Home folder (host system) of the registered user, used by the TLS ftp server. 287 | ``FTP_CERTFILE`` Certificate (host system) to be used by the TLS ftp server. 288 | ===================== ============================================================================= 289 | 290 | You can either set environment variables on a system level or use tools such as 291 | `pytest-env `_ or 292 | `tox `_, which would be the recommended way. 293 | 294 | .. note:: You might run into ``OSError: [Errno 48] Address already in use`` when setting a fixed port 295 | (``FTP_PORT``/ ``FTP_PORT_TLS``). 296 | This is due to the server still listening on that port, which prevents it from adding another listener 297 | on that port. When using pythons buildin ``ftplib``, you should use the 298 | `quit method `_ 299 | to terminate the connection, since it's the `'the “polite” way to close a connection'` and lets the 300 | server know that the client isn't just experiencing connection problems, but won't come back. 301 | 302 | Configuration with pytest-env 303 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 304 | The configuration of pytest-env is done in the ``pytest.ini`` file. 305 | The following example configuration will use the username ``benz``, the password ``erni1``, 306 | the ftp port ``31175`` and the home folder ``/home/ftp_test``. 307 | For the encrypted version of the fixture it uses port ``31176``, the home folder ``/home/ftp_test`` and 308 | the certificate ``./tests/test_keycert.pem``:: 309 | 310 | $ cat pytest.ini 311 | [pytest] 312 | env = 313 | FTP_USER=benz 314 | FTP_PASS=erni1 315 | FTP_HOME = /home/ftp_test 316 | FTP_PORT=31175 317 | FTP_FIXTURE_SCOPE=function 318 | # only affects ftpserver_TLS 319 | FTP_PORT_TLS = 31176 320 | FTP_HOME_TLS = /home/ftp_test_TLS 321 | FTP_CERTFILE = ./tests/test_keycert.pem 322 | 323 | 324 | Configuration with Tox 325 | ^^^^^^^^^^^^^^^^^^^^^^ 326 | 327 | The configuration of tox is done in the ``tox.ini`` file. 328 | The following example configuration will run the tests in the folder ``tests`` on 329 | python 3.6+ and use the username ``benz``, the password ``erni1``, 330 | the tempfolder of each virtual environment the tests are run in (``{envtmpdir}``) and 331 | the ftp port ``31175``. 332 | For the encrypted version of the fixture it uses port ``31176`` and the certificate 333 | ``{toxinidir}/tests/test_keycert.pem``:: 334 | 335 | $ cat tox.ini 336 | [tox] 337 | envlist = py{36,37,38,39,310} 338 | 339 | [testenv] 340 | setenv = 341 | FTP_USER=benz 342 | FTP_PASS=erni1 343 | FTP_HOME = {envtmpdir} 344 | FTP_PORT=31175 345 | FTP_FIXTURE_SCOPE=function 346 | # only affects ftpserver_TLS 347 | FTP_PORT_TLS = 31176 348 | FTP_HOME_TLS = /home/ftp_test_TLS 349 | FTP_CERTFILE = {toxinidir}/tests/test_keycert.pem 350 | commands = 351 | pytest tests 352 | 353 | -------------------------------------------------------------------------------- /tests/test_pytest_localftpserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | test_pytest_localftpserver 5 | ---------------------------------- 6 | 7 | Tests for `pytest_localftpserver` module. 8 | """ 9 | 10 | from ftplib import FTP, FTP_TLS, error_perm 11 | import logging 12 | import os 13 | import urllib.request 14 | 15 | import pytest 16 | import ssl 17 | 18 | from pytest_localftpserver.plugin import PytestLocalFTPServer 19 | from pytest_localftpserver.servers import USE_PROCESS 20 | from pytest_localftpserver.helper_functions import DEFAULT_CERTFILE 21 | 22 | 23 | from ssl import SSLContext, PROTOCOL_TLS_CLIENT, TLSVersion 24 | 25 | # HELPER FUNCTIONS 26 | 27 | 28 | FILE_LIST = [ 29 | ("", "testfile1"), 30 | ("testdir", "testfile2"), 31 | ("testdir/nested", "testfile3"), 32 | ] 33 | 34 | 35 | def ftp_login(ftp_fixture, anon=False, use_TLS=False): 36 | """ 37 | Convenience function to reduce code overhead. 38 | Logs in the FTP client and returns the ftplib.FTP instance 39 | 40 | Parameters 41 | ---------- 42 | ftp_fixture: FTPServer 43 | 44 | anon: bool 45 | True: 46 | Use anon_root as basepath 47 | 48 | False: 49 | Use server_home as basepath 50 | 51 | Returns 52 | ------- 53 | ftp: ftplib.FTP 54 | logged in FTP client 55 | 56 | """ 57 | if use_TLS: 58 | ssl_context = SSLContext(PROTOCOL_TLS_CLIENT) 59 | ssl_context.minimum_version = TLSVersion.TLSv1_2 60 | ssl_context.maximum_version = TLSVersion.TLSv1_3 61 | ssl_context.load_cert_chain(certfile=DEFAULT_CERTFILE) 62 | ftp = FTP_TLS(context=ssl_context) 63 | # Disable certificate verification for self-signed certificates in tests 64 | ssl_context.check_hostname = False 65 | ssl_context.verify_mode = ssl.CERT_NONE 66 | ftp = FTP_TLS(context=ssl_context) 67 | else: 68 | ftp = FTP() 69 | login_dict = ftp_fixture.get_login_data() 70 | ftp.connect(login_dict["host"], login_dict["port"]) 71 | if anon: 72 | ftp.login() 73 | else: 74 | ftp.login(login_dict["user"], login_dict["passwd"]) 75 | if use_TLS: 76 | ftp.prot_p() 77 | return ftp 78 | 79 | 80 | def close_client(session): 81 | # taken from https://github.com/giampaolo/pyftpdlib/ \ 82 | # blob/master/pyftpdlib/test/__init__.py 83 | """Closes a ftplib.FTP client session.""" 84 | try: 85 | if session.sock is not None: 86 | try: 87 | resp = session.quit() 88 | except Exception: 89 | pass 90 | else: 91 | # ...just to make sure the server isn't replying to some 92 | # pending command. 93 | assert resp.startswith("221"), resp 94 | finally: 95 | session.close() 96 | 97 | 98 | def check_files_by_ftpclient( 99 | ftp_fixture, 100 | tmpdir, 101 | files_on_server, 102 | path_iterable, 103 | anon=False, 104 | use_TLS=False 105 | ): 106 | """ 107 | Convenience function to reduce code overhead. 108 | Downloading files with a native ftp client and checking their content. 109 | 110 | Parameters 111 | ---------- 112 | ftp_fixture: FTPServer 113 | 114 | anon: bool 115 | True: 116 | Use anon_root as basepath 117 | 118 | False: 119 | Use server_home as basepath 120 | 121 | Returns 122 | ------- 123 | ftp: ftplib.FTP 124 | logged in FTP client 125 | """ 126 | # checking the files by rel_path to user home dir 127 | # and native ftp client 128 | if anon: 129 | base_path = ftp_fixture.anon_root 130 | else: 131 | base_path = ftp_fixture.server_home 132 | ftp = ftp_login(ftp_fixture, anon=anon, use_TLS=use_TLS) 133 | download_dir = tmpdir.mkdir("download_rel_path") 134 | for file_path in path_iterable: 135 | abs_file_path = os.path.abspath(os.path.join(base_path, file_path)) 136 | assert os.path.isfile(abs_file_path) 137 | assert file_path in files_on_server 138 | dirs, filename = os.path.split(file_path) 139 | if dirs != "": 140 | download_file = download_dir.mkdir(dirs).join(filename) 141 | else: 142 | download_file = download_dir.join(filename) 143 | with open(str(download_file), "wb") as f: 144 | ftp.retrbinary("RETR " + file_path, f.write) 145 | with open(str(download_file)) as f: 146 | assert f.read() == filename 147 | close_client(ftp) 148 | download_dir.remove() 149 | 150 | 151 | def check_files_by_urls(tmpdir, base_url, url_iterable): 152 | """ 153 | Convenience function to reduce code overhead. 154 | Downloading files by urls and checking their content. 155 | 156 | Parameters 157 | ---------- 158 | tmpdir: Path 159 | Tempdir to download files to 160 | base_url: str 161 | Base ur to the ftp server to mimic its folder structure 162 | url_iterable: iterable of urls 163 | Contains urls to check 164 | """ 165 | # checking files by url 166 | for url in url_iterable: 167 | _, filename = os.path.split(os.path.relpath(url, base_url)) 168 | with urllib.request.urlopen(url) as response: 169 | assert response.read() == filename.encode() 170 | 171 | 172 | def check_get_file_contents( 173 | tmpdir, 174 | path_list, 175 | iterable_len, 176 | files_on_server, 177 | style, 178 | base_url, 179 | read_mode 180 | ): 181 | """ 182 | Convenience function to reduce code overhead. 183 | Compares expected file content with actual file content. 184 | 185 | Parameters 186 | ---------- 187 | tmpdir: Path 188 | Tempdir to download files to 189 | path_list: list 190 | List of filepaths to check 191 | iterable_len: int 192 | Len of path_list 193 | files_on_server: list 194 | List of relative file path on the server 195 | style: "rel_path", "url" 196 | Mode in which filepaths on the ftpserver are given 197 | base_url: str 198 | Base ur to the ftp server to mimic its folder structure 199 | read_mode: "r", "rb" 200 | Mode in which files should be read 201 | 202 | Returns 203 | ------- 204 | 205 | """ 206 | assert len(path_list) == iterable_len 207 | for content_dict in path_list: 208 | assert isinstance(content_dict, dict) 209 | assert "path" in content_dict and "content" in content_dict 210 | file_content = os.path.split(content_dict["path"])[1] 211 | if read_mode == "rb": 212 | file_content = file_content.encode() 213 | if style == "rel_path": 214 | assert content_dict["path"] in files_on_server 215 | elif style == "url": 216 | check_files_by_urls(tmpdir, base_url, [content_dict["path"]]) 217 | assert content_dict["content"] == file_content 218 | 219 | 220 | def run_ftp_stopped_test(ftpserver_fixture): 221 | """ 222 | Tests if the Server is unreachable after shutdown, by checking if a client 223 | that tries to connect raises an exception. 224 | 225 | Parameters 226 | ---------- 227 | ftpserver_fixture: PytestLocalFTPServer 228 | 229 | """ 230 | ftpserver_fixture.stop() 231 | ftp = FTP() 232 | if USE_PROCESS: 233 | with pytest.raises((ConnectionRefusedError, ConnectionResetError)): 234 | ftp.connect("localhost", port=ftpserver_fixture.server_port) 235 | else: 236 | with pytest.raises(OSError): 237 | ftp.connect("localhost", port=ftpserver_fixture.server_port) 238 | 239 | 240 | # ACTUAL TESTS 241 | 242 | 243 | def test_ftpserver_class(ftpserver): 244 | assert isinstance(ftpserver, PytestLocalFTPServer) 245 | assert ftpserver.uses_TLS is False 246 | 247 | 248 | @pytest.mark.parametrize("anon", [True, False]) 249 | def test_get_login_data(ftpserver, anon): 250 | login_dict = ftpserver.get_login_data(style="dict") 251 | assert login_dict["host"] == "localhost" 252 | assert login_dict["port"] == ftpserver.server_port 253 | if not anon: 254 | assert login_dict["user"] == "fakeusername" 255 | assert login_dict["passwd"] == "qweqwe" 256 | login_url = ftpserver.get_login_data(style="url", anon=anon) 257 | if anon: 258 | base_url = "ftp://localhost:" 259 | else: 260 | base_url = "ftp://fakeusername:qweqwe@localhost:" 261 | assert login_url == base_url + str(ftpserver.server_port) 262 | 263 | 264 | def test_get_login_data_exceptions(ftpserver): 265 | # type errors 266 | with pytest.raises( 267 | TypeError, 268 | match="The Argument `style` needs to be of type " 269 | "``str``, the type given type was " 270 | "``bool``.", 271 | ): 272 | ftpserver.get_login_data(style=True) 273 | with pytest.raises( 274 | TypeError, 275 | match="The Argument `anon` needs to be of type " 276 | "``bool``, the type given type was " 277 | "``str``.", 278 | ): 279 | ftpserver.get_login_data(anon="not_bool") 280 | 281 | # value errors 282 | with pytest.raises( 283 | ValueError, 284 | match="The Argument `style` needs to be of value " 285 | "'dict' or 'url', the given value was " 286 | "'rel_path'.", 287 | ): 288 | ftpserver.get_login_data(style="rel_path") 289 | 290 | 291 | @pytest.mark.parametrize("is_posix", [True, False]) 292 | @pytest.mark.parametrize("anon", [True, False]) 293 | def test_format_file_path(ftpserver, anon, is_posix): 294 | if is_posix: 295 | path_sep_char = "/" 296 | else: 297 | path_sep_char = "\\" 298 | base_url = ftpserver.get_login_data(style="url", anon=anon) 299 | 300 | rel_file_path = path_sep_char.join(["test_dir", "test_file"]) 301 | rel_path = ftpserver.format_file_path(rel_file_path, anon=anon) 302 | assert rel_path == "test_dir/test_file" 303 | 304 | url_result = base_url + "/test_dir/test_file" 305 | url = ftpserver.format_file_path(rel_file_path, style="url", anon=anon) 306 | assert url == url_result 307 | 308 | 309 | def test_format_file_path_exceptions(ftpserver): 310 | # type errors 311 | with pytest.raises( 312 | TypeError, 313 | match="The Argument `rel_file_path` needs to be of type " 314 | "``str``, the type given type was " 315 | "``int``.", 316 | ): 317 | ftpserver.format_file_path(1) 318 | # type errors 319 | with pytest.raises( 320 | TypeError, 321 | match="The Argument `style` needs to be of type " 322 | "``str``, the type given type was " 323 | "``bool``.", 324 | ): 325 | ftpserver.format_file_path("test/file", style=True) 326 | with pytest.raises( 327 | TypeError, 328 | match="The Argument `anon` needs to be of type " 329 | "``bool``, the type given type was " 330 | "``str``.", 331 | ): 332 | ftpserver.format_file_path("test/file", anon="not_bool") 333 | 334 | # value errors 335 | with pytest.raises( 336 | ValueError, 337 | match="The Argument `style` needs to be of value " 338 | "'rel_path' or 'url', the given value was " 339 | "'dict'.", 340 | ): 341 | ftpserver.format_file_path("test/file", style="dict") 342 | 343 | 344 | @pytest.mark.parametrize("anon", [True, False]) 345 | def test_get_local_base_path(ftpserver, anon): 346 | # makes sure to start with clean temp dirs 347 | ftpserver.reset_tmp_dirs() 348 | local_path = ftpserver.get_local_base_path(anon=anon) 349 | assert os.path.isdir(local_path) 350 | if anon: 351 | path_substr = "anon_root_" 352 | else: 353 | path_substr = "ftp_home_" 354 | assert path_substr in local_path 355 | 356 | 357 | def test_get_local_base_path_exceptions(ftpserver): 358 | # type errors 359 | with pytest.raises( 360 | TypeError, 361 | match="The Argument `anon` needs to be of type " 362 | "``bool``, the type given type was " 363 | "``str``.", 364 | ): 365 | ftpserver.get_local_base_path(anon="not_bool") 366 | 367 | 368 | def test_file_upload_user(ftpserver, tmpdir): 369 | # makes sure to start with clean temp dirs 370 | ftpserver.reset_tmp_dirs() 371 | ftp = ftp_login(ftpserver) 372 | ftp.cwd("/") 373 | ftp.mkd("FOO") 374 | ftp.cwd("FOO") 375 | filename = "testfile.txt" 376 | file_path_local = tmpdir.join(filename) 377 | file_path_local.write("test") 378 | with open(str(file_path_local), "rb") as f: 379 | ftp.storbinary("STOR " + filename, f) 380 | close_client(ftp) 381 | 382 | assert os.path.isdir(os.path.join(ftpserver.server_home, "FOO")) 383 | abs_file_path_server = os.path.join(ftpserver.server_home, "FOO", filename) 384 | assert os.path.isfile(abs_file_path_server) 385 | with open(abs_file_path_server) as f: 386 | assert f.read() == "test" 387 | 388 | 389 | def test_file_upload_anon(ftpserver): 390 | # anon user has no write privileges 391 | ftp = ftp_login(ftpserver, anon=True) 392 | ftp.cwd("/") 393 | with pytest.raises(error_perm): 394 | ftp.mkd("FOO") 395 | close_client(ftp) 396 | 397 | 398 | @pytest.mark.parametrize("anon", [True, False]) 399 | def test_get_file_paths(tmpdir, ftpserver, anon): 400 | # makes sure to start with clean temp dirs 401 | ftpserver.reset_tmp_dirs() 402 | base_path = ftpserver.get_local_base_path(anon=anon) 403 | files_on_server = [] 404 | for dirs, filename in FILE_LIST: 405 | dir_path = os.path.abspath(os.path.join(base_path, dirs)) 406 | if dirs != "": 407 | os.makedirs(dir_path) 408 | abs_file_path = os.path.join(dir_path, filename) 409 | file_path = "/".join([dirs, filename]).lstrip("/") 410 | files_on_server.append(file_path) 411 | with open(abs_file_path, "a") as f: 412 | f.write(filename) 413 | 414 | path_iterable = list(ftpserver.get_file_paths(anon=anon)) 415 | assert len(path_iterable) == len(FILE_LIST) 416 | # checking the files by rel_path to user home dir 417 | # and native ftp client 418 | check_files_by_ftpclient( 419 | ftpserver, 420 | tmpdir, 421 | files_on_server, 422 | path_iterable, 423 | anon) 424 | 425 | # checking files by url 426 | url_iterable = list(ftpserver.get_file_paths(style="url", anon=anon)) 427 | 428 | base_url = ftpserver.get_login_data(style="url", anon=anon) 429 | check_files_by_urls(tmpdir, base_url, url_iterable) 430 | 431 | 432 | def test_get_file_paths_exceptions(ftpserver): 433 | # type errors 434 | with pytest.raises( 435 | TypeError, 436 | match="The Argument `style` needs to be of type " 437 | "``str``, the type given type was " 438 | "``bool``.", 439 | ): 440 | list(ftpserver.get_file_paths(style=True)) 441 | 442 | with pytest.raises( 443 | TypeError, 444 | match="The Argument `anon` needs to be of type " 445 | "``bool``, the type given type was " 446 | "``str``.", 447 | ): 448 | list(ftpserver.get_file_paths(anon="not_bool")) 449 | 450 | # value errors 451 | with pytest.raises( 452 | ValueError, 453 | match="The Argument `style` needs to be of value " 454 | "'rel_path' or 'url', the given value was " 455 | "'dict'.", 456 | ): 457 | list(ftpserver.get_file_paths(style="dict")) 458 | 459 | 460 | @pytest.mark.parametrize("anon", [True, False]) 461 | @pytest.mark.parametrize( 462 | "file_rel_paths", 463 | [None, "testfile1", ["testdir/testfile2", "testdir/nested/testfile3"]], 464 | ) 465 | @pytest.mark.parametrize("style", ["rel_path", "url"]) 466 | @pytest.mark.parametrize("read_mode", ["r", "rb"]) 467 | def test_get_file_contents( 468 | tmpdir, 469 | ftpserver, 470 | anon, 471 | file_rel_paths, 472 | style, 473 | read_mode): 474 | ftpserver.reset_tmp_dirs() 475 | base_path = ftpserver.get_local_base_path(anon=anon) 476 | base_url = ftpserver.get_login_data(style="url", anon=anon) 477 | 478 | # write files to ftp home of user 479 | files_on_server = [] 480 | for dirs, filename in FILE_LIST: 481 | dir_path = os.path.abspath(os.path.join(base_path, dirs)) 482 | if dirs != "": 483 | os.makedirs(dir_path) 484 | abs_file_path = os.path.join(dir_path, filename) 485 | file_path = "/".join([dirs, filename]).lstrip("/") 486 | files_on_server.append(file_path) 487 | with open(abs_file_path, "a") as f: 488 | f.write(filename) 489 | 490 | # tests for file_rel_paths being relative path 491 | if file_rel_paths is None: 492 | iterable_len = len(FILE_LIST) 493 | elif isinstance(file_rel_paths, str): 494 | iterable_len = 1 495 | else: 496 | iterable_len = len(file_rel_paths) 497 | 498 | path_list = list( 499 | ftpserver.get_file_contents( 500 | rel_file_paths=file_rel_paths, 501 | anon=anon, 502 | style=style, 503 | read_mode=read_mode 504 | ) 505 | ) 506 | 507 | check_get_file_contents( 508 | tmpdir=tmpdir, 509 | path_list=path_list, 510 | iterable_len=iterable_len, 511 | files_on_server=files_on_server, 512 | style=style, 513 | base_url=base_url, 514 | read_mode=read_mode, 515 | ) 516 | 517 | with pytest.raises( 518 | ValueError, 519 | match=r"not a valid relative file path or url."): 520 | list(ftpserver.get_file_contents( 521 | rel_file_paths="not a file path", 522 | anon=anon)) 523 | 524 | # tests for file_rel_paths being urls 525 | if isinstance(file_rel_paths, str): 526 | file_rel_paths = base_url + "/" + file_rel_paths 527 | elif isinstance(file_rel_paths, list): 528 | file_rel_paths = [ 529 | base_url + "/" + file_rel_path for file_rel_path in file_rel_paths 530 | ] 531 | 532 | path_list = list( 533 | ftpserver.get_file_contents( 534 | rel_file_paths=file_rel_paths, 535 | anon=anon, 536 | style=style, 537 | read_mode=read_mode 538 | ) 539 | ) 540 | 541 | check_get_file_contents( 542 | tmpdir=tmpdir, 543 | path_list=path_list, 544 | iterable_len=iterable_len, 545 | files_on_server=files_on_server, 546 | style=style, 547 | base_url=base_url, 548 | read_mode=read_mode, 549 | ) 550 | 551 | with pytest.raises( 552 | ValueError, 553 | match=r"not a valid relative file path or url."): 554 | list( 555 | ftpserver.get_file_contents( 556 | rel_file_paths="ftp://some-other-server", anon=anon 557 | ) 558 | ) 559 | 560 | 561 | def test_get_file_contents_exceptions(ftpserver): 562 | # type errors 563 | with pytest.raises( 564 | TypeError, 565 | match="The Argument `rel_file_paths` needs to be of type " 566 | "``NoneType``, ``str`` or ``Iterable``, the type given " 567 | "type was ``int``.", 568 | ): 569 | list(ftpserver.get_file_contents(rel_file_paths=1)) 570 | 571 | with pytest.raises( 572 | TypeError, 573 | match="The Argument `style` needs to be of type " 574 | "``str``, the type given type was " 575 | "``bool``.", 576 | ): 577 | list(ftpserver.get_file_contents(style=True)) 578 | 579 | with pytest.raises( 580 | TypeError, 581 | match="The Argument `read_mode` needs to be of type " 582 | "``str``, the type given type was " 583 | "``bool``.", 584 | ): 585 | list(ftpserver.get_file_contents(read_mode=True)) 586 | 587 | with pytest.raises( 588 | TypeError, 589 | match="The Argument `anon` needs to be of type " 590 | "``bool``, the type given type was " 591 | "``str``.", 592 | ): 593 | list(ftpserver.get_file_contents(anon="not_bool")) 594 | 595 | # value errors 596 | with pytest.raises( 597 | ValueError, 598 | match="The Argument `style` needs to be of value " 599 | "'rel_path' or 'url', the given value was " 600 | "'dict'.", 601 | ): 602 | list(ftpserver.get_file_contents(style="dict")) 603 | 604 | with pytest.raises( 605 | ValueError, 606 | match="The Argument `read_mode` needs to be of value " 607 | "'r' or 'rb', the given value was " 608 | "'invalid_option'.", 609 | ): 610 | list(ftpserver.get_file_contents(read_mode="invalid_option")) 611 | 612 | 613 | @pytest.mark.parametrize("use_dict", [True, False]) 614 | @pytest.mark.parametrize("style", ["rel_path", "url"]) 615 | @pytest.mark.parametrize("anon", [True, False]) 616 | @pytest.mark.parametrize("overwrite", [True, False]) 617 | @pytest.mark.parametrize("return_paths", ["all", "input", "new"]) 618 | @pytest.mark.parametrize("return_content", [True, False]) 619 | @pytest.mark.parametrize("read_mode", ["r", "rb"]) 620 | def test_put_files( 621 | tmpdir, 622 | ftpserver, 623 | use_dict, 624 | style, 625 | anon, 626 | overwrite, 627 | return_paths, 628 | return_content, 629 | read_mode, 630 | ): 631 | """ 632 | This test breaks if test_get_files breaks 633 | """ 634 | # makes sure to start with clean temp dirs 635 | ftpserver.reset_tmp_dirs() 636 | base_url = ftpserver.get_login_data(style="url", anon=anon) 637 | files_on_server = [] 638 | files_on_local = [] 639 | local_dir = tmpdir.mkdir("local_dir") 640 | for dirs, filename in FILE_LIST: 641 | if dirs != "": 642 | test_file = local_dir.mkdir(dirs).join(filename) 643 | else: 644 | test_file = local_dir.join(filename) 645 | test_file.write(filename) 646 | if not use_dict: 647 | file_path = filename 648 | files_on_local.append(str(test_file)) 649 | else: 650 | file_path = "/".join([dirs, filename]).lstrip("/") 651 | file_dict = {"src": str(test_file), "dest": file_path} 652 | files_on_local.append(file_dict) 653 | files_on_server.append(file_path) 654 | 655 | put_files_return = list( 656 | ftpserver.put_files( 657 | files_on_local=files_on_local, 658 | style=style, 659 | anon=anon, 660 | overwrite=overwrite, 661 | return_paths=return_paths, 662 | return_content=return_content, 663 | read_mode=read_mode, 664 | ) 665 | ) 666 | assert len(put_files_return) == len(FILE_LIST) 667 | 668 | if style == "rel_path": 669 | if not return_content: 670 | check_files_by_ftpclient( 671 | ftp_fixture=ftpserver, 672 | tmpdir=tmpdir, 673 | files_on_server=files_on_server, 674 | path_iterable=put_files_return, 675 | anon=anon, 676 | ) 677 | else: 678 | check_get_file_contents( 679 | tmpdir=tmpdir, 680 | path_list=put_files_return, 681 | iterable_len=len(FILE_LIST), 682 | files_on_server=files_on_server, 683 | style=style, 684 | base_url=base_url, 685 | read_mode=read_mode, 686 | ) 687 | 688 | elif style == "url": 689 | if not return_content: 690 | check_files_by_urls( 691 | tmpdir=tmpdir, base_url=base_url, url_iterable=put_files_return 692 | ) 693 | else: 694 | check_get_file_contents( 695 | tmpdir=tmpdir, 696 | path_list=put_files_return, 697 | iterable_len=len(FILE_LIST), 698 | files_on_server=files_on_server, 699 | style=style, 700 | base_url=base_url, 701 | read_mode=read_mode, 702 | ) 703 | 704 | # testing overwrite funtionality 705 | overwrite_file = files_on_local[0] 706 | 707 | if not use_dict: 708 | overwrite_result = os.path.split(overwrite_file)[1] 709 | else: 710 | overwrite_result = overwrite_file["dest"] 711 | overwrite_result = ftpserver.format_file_path( 712 | overwrite_result, style=style, anon=anon 713 | ) 714 | 715 | if overwrite: 716 | overwrite_put_files_return = ftpserver.put_files( 717 | overwrite_file, 718 | style=style, 719 | anon=anon, 720 | overwrite=overwrite, 721 | return_paths=return_paths, 722 | ) 723 | if return_paths == "new": 724 | assert overwrite_put_files_return == [overwrite_result] 725 | if return_paths == "input": 726 | assert overwrite_put_files_return == [overwrite_result] 727 | elif return_paths == "all": 728 | overwrite_result = list( 729 | ftpserver.get_file_paths(style=style, anon=anon)) 730 | assert list(overwrite_put_files_return) == overwrite_result 731 | 732 | else: 733 | with pytest.warns( 734 | UserWarning, 735 | match=r"already exist and won't be overwritten"): 736 | overwrite_put_files_return = ftpserver.put_files( 737 | overwrite_file, 738 | style=style, 739 | anon=anon, 740 | overwrite=overwrite, 741 | return_paths=return_paths, 742 | ) 743 | if return_paths == "new": 744 | assert overwrite_put_files_return == [] 745 | elif return_paths == "input": 746 | assert overwrite_put_files_return == [overwrite_result] 747 | elif return_paths == "all": 748 | overwrite_result = list( 749 | ftpserver.get_file_paths(style=style, anon=anon) 750 | ) 751 | assert list(overwrite_put_files_return) == overwrite_result 752 | 753 | # delete local files 754 | local_dir.remove() 755 | 756 | 757 | def test_put_files_exceptions(ftpserver, tmpdir): 758 | valid_file = tmpdir.join("valid_file") 759 | valid_file.write("valid_file") 760 | 761 | # testing with invalid "files" 762 | 763 | with pytest.raises(ValueError, match=r"is not a valid file path"): 764 | not_a_file = "not_a_file" 765 | ftpserver.put_files(not_a_file) 766 | 767 | with pytest.raises(ValueError, match=r"is not a valid file path"): 768 | not_a_file = {"src": "not_a_file", "dest": "does_not_matter"} 769 | ftpserver.put_files(not_a_file) 770 | 771 | # wrong/missing key for dict 772 | with pytest.raises(KeyError, match=r"the dicts need to have the Keys "): 773 | ftpserver.put_files({"wrong_key": "doesn't matter"}) 774 | 775 | with pytest.raises(KeyError, match=r"the dicts need to have the Keys "): 776 | ftpserver.put_files({"src": "doesn't matter"}) 777 | 778 | with pytest.raises(KeyError, match=r"the dicts need to have the Keys "): 779 | ftpserver.put_files({"dest": "doesn't matter"}) 780 | 781 | # type errors in options 782 | 783 | with pytest.raises(TypeError, match=r"has to be of type"): 784 | ftpserver.put_files( 785 | 1, 786 | ) 787 | 788 | with pytest.raises( 789 | TypeError, 790 | match="The Argument `style` needs to be of type " 791 | "``str``, the type given type was " 792 | "``bool``.", 793 | ): 794 | ftpserver.put_files( 795 | "doesn't matter since option check is be4", style=True) 796 | 797 | with pytest.raises( 798 | TypeError, 799 | match="The Argument `anon` needs to be of type " 800 | "``bool``, the type given type was " 801 | "``str``.", 802 | ): 803 | ftpserver.put_files( 804 | "doesn't matter since option check is be4", anon="not_bool") 805 | 806 | with pytest.raises( 807 | TypeError, 808 | match="The Argument `overwrite` needs to be of type " 809 | "``bool``, the type given type was ``str``.", 810 | ): 811 | ftpserver.put_files( 812 | "doesn't matter since option check is be4", overwrite="not_bool" 813 | ) 814 | 815 | with pytest.raises( 816 | TypeError, 817 | match="The Argument `return_paths` needs to be of type " 818 | "``str``, the type given type was ``bool``.", 819 | ): 820 | ftpserver.put_files( 821 | "doesn't matter since option check is be4", return_paths=True 822 | ) 823 | 824 | with pytest.raises( 825 | TypeError, 826 | match="The Argument `return_content` needs to be of type " 827 | "``bool``, the type given type was ``str``.", 828 | ): 829 | ftpserver.put_files( 830 | "doesn't matter since option check is be4", 831 | return_content="not_bool" 832 | ) 833 | 834 | with pytest.raises( 835 | TypeError, 836 | match="The Argument `read_mode` needs to be of type " 837 | "``str``, the type given type was ``bool``.", 838 | ): 839 | ftpserver.put_files( 840 | "doesn't matter since option check is be4", 841 | read_mode=True) 842 | 843 | # value errors 844 | with pytest.raises( 845 | ValueError, 846 | match="The Argument `style` needs to be of value " 847 | "'rel_path' or 'url', the given value was " 848 | "'dict'.", 849 | ): 850 | ftpserver.put_files( 851 | "doesn't matter since option check is be4", 852 | style="dict") 853 | 854 | with pytest.raises( 855 | ValueError, 856 | match="The Argument `return_paths` needs to be of value " 857 | "'all', 'input' or 'new', the given value was " 858 | "'invalid_option'.", 859 | ): 860 | ftpserver.put_files( 861 | "doesn't matter since option check is be4", 862 | return_paths="invalid_option" 863 | ) 864 | 865 | with pytest.raises( 866 | ValueError, 867 | match="The Argument `read_mode` needs to be of value " 868 | "'r' or 'rb', the given value was " 869 | "'invalid_option'.", 870 | ): 871 | ftpserver.put_files( 872 | "doesn't matter since option check is be4", 873 | read_mode="invalid_option" 874 | ) 875 | 876 | 877 | def test_option_validator_logging(caplog, ftpserver): 878 | with caplog.at_level(logging.INFO): 879 | 880 | caplog.clear() 881 | 882 | ftpserver.__test_option_validator_logging__(1, 3) 883 | log_output = ( 884 | "\n" 885 | "##################################################\n" 886 | "# __TEST_OPTION_VALIDATOR_LOGGING__ #\n" 887 | "##################################################\n" 888 | "\n\n" 889 | "FUNC_LOCALS\n" 890 | "'a': 1\n" 891 | "'b': 3\n\n\n\n" 892 | ) 893 | 894 | assert [log_output] == [rec.message for rec in caplog.records] 895 | 896 | 897 | def test_ftp_stopped(ftpserver): 898 | local_anon_path = ftpserver.get_local_base_path(anon=True) 899 | local_ftp_home = ftpserver.get_local_base_path(anon=False) 900 | run_ftp_stopped_test(ftpserver) 901 | # check if all temp folders got cleared properly 902 | assert not os.path.exists(local_anon_path) 903 | assert not os.path.exists(local_ftp_home) 904 | 905 | 906 | def test_fail_due_to_closed_module_scope(ftpserver): 907 | """This test is just meant to confirm that the server 908 | is down on module scope""" 909 | ftp = FTP() 910 | with pytest.raises(Exception): 911 | ftp.connect("localhost", port=ftpserver.server_port) 912 | -------------------------------------------------------------------------------- /pytest_localftpserver/servers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from functools import wraps 3 | import multiprocessing 4 | import os 5 | import shutil 6 | import sys 7 | from sys import excepthook as _excepthook 8 | import tempfile 9 | import threading 10 | import warnings 11 | 12 | from pyftpdlib.authorizers import DummyAuthorizer 13 | from pyftpdlib.handlers import FTPHandler, TLS_FTPHandler 14 | from pyftpdlib.servers import FTPServer 15 | 16 | from pytest_localftpserver.helper_functions import ( 17 | get_socket, 18 | get_env_dict, 19 | validate_cert_file, 20 | arg_validator, 21 | arg_validator_excepthook, 22 | pretty_logger, 23 | DEFAULT_CERTFILE, 24 | ) 25 | 26 | 27 | # uncomment the next line to log _option_validator for debugging 28 | # import logging 29 | # logging.basicConfig(filename='option_validator.log', level=logging.INFO) 30 | 31 | 32 | class WrongFixtureError(Exception): 33 | pass 34 | 35 | 36 | class SimpleFTPServer(FTPServer): 37 | """ 38 | Starts a simple FTP server. 39 | 40 | https://github.com/Lukasa/requests-ftp/ 41 | 42 | Parameters 43 | ---------- 44 | username: str 45 | Name of the registered user. 46 | password: str 47 | Password of the registered user. 48 | ftp_home: str 49 | Local path to FTP home for the registered user. 50 | ftp_port: int 51 | Desired port for the server to listen to. 52 | use_TLS: bool 53 | Whether or not to use TLS/SSL encryption. 54 | certfile: str, Path 55 | Path to the certificate file. 56 | """ 57 | 58 | def __init__( 59 | self, 60 | username="fakeusername", 61 | password="qweqwe", 62 | ftp_home=None, 63 | ftp_port=0, 64 | use_TLS=False, 65 | certfile=DEFAULT_CERTFILE, 66 | ): 67 | # Create temp directories for the anonymous and authenticated roots 68 | self._anon_root = tempfile.mkdtemp(prefix="anon_root_") 69 | if not ftp_home: 70 | self.temp_ftp_home = True 71 | if use_TLS: 72 | self._ftp_home = tempfile.mkdtemp(prefix="ftp_home_TLS_") 73 | else: 74 | self._ftp_home = tempfile.mkdtemp(prefix="ftp_home_") 75 | else: 76 | self.temp_ftp_home = False 77 | self._ftp_home = ftp_home 78 | self.username = username 79 | self.password = password 80 | authorizer = DummyAuthorizer() 81 | authorizer.add_user( 82 | self.username, self.password, self._ftp_home, perm="elradfmwM" 83 | ) 84 | authorizer.add_anonymous(self._anon_root) 85 | 86 | self._uses_TLS = use_TLS 87 | self._cert_path = certfile 88 | 89 | if use_TLS: 90 | handler = TLS_FTPHandler 91 | handler.certfile = certfile 92 | validate_cert_file(certfile) 93 | else: 94 | handler = FTPHandler 95 | 96 | socket, self._ftp_port = get_socket(ftp_port) 97 | 98 | handler.authorizer = authorizer 99 | 100 | # Create a new pyftpdlib server with the socket and handler we've 101 | # configured 102 | FTPServer.__init__(self, socket, handler) 103 | 104 | def stop(self): 105 | """ 106 | Stops the server, closes all the open ports and deletes all temp files 107 | """ 108 | self.close_all() 109 | self.clear_tmp_dirs() 110 | 111 | def clear_tmp_dirs(self): 112 | """ 113 | Clears all temp files generated on the FTP server 114 | """ 115 | shutil.rmtree(self._anon_root, ignore_errors=True) 116 | if self.temp_ftp_home: 117 | shutil.rmtree(self._ftp_home, ignore_errors=True) 118 | 119 | def reset_tmp_dirs(self): 120 | """ 121 | Clears all temp files generated on the FTP server and 122 | recreates the base dirs on the server. 123 | This method is implemented to have more control over 124 | the session based ftp server. 125 | """ 126 | self.clear_tmp_dirs() 127 | # checking if the folder still exists prevents an error 128 | # being raised by os.makedirs 129 | if not os.path.exists(self._anon_root): # pragma: no branch 130 | os.makedirs(self._anon_root) 131 | if self.temp_ftp_home: # pragma: no branch 132 | if not os.path.exists(self._ftp_home): # pragma: no branch 133 | os.makedirs(self._ftp_home) 134 | 135 | 136 | class FunctionalityWrapper: 137 | """ 138 | Baseclass which holds the functionality of ftpserver. 139 | The derived classes are ThreadFTPServer and ProcessFTPServer, which 140 | (depending on the OS) are the classes of the ftpserver instance. 141 | 142 | Parameters 143 | ---------- 144 | use_TLS: bool 145 | Whether or not to use TLS/SSL encryption. 146 | 147 | Notes 148 | ----- 149 | 150 | For custom configuration the following environment variables can be used: 151 | 152 | General: 153 | 154 | FTP_USER: str 155 | Name of the registered user. 156 | FTP_PASS: str 157 | Password of the registered user. 158 | FTP_HOME: str 159 | Local path to FTP home for the registered user. 160 | FTP_PORT: int 161 | Desired port for the unencrypted server to listen to. 162 | FTP_FIXTURE_SCOPE: {'function', 'module', 'session'}: default 'module' 163 | Scope the fixture will be in. 164 | 165 | TLS only: 166 | 167 | FTP_HOME_TLS = str 168 | Local path to FTP home for the registered user of the encrypted server. 169 | FTP_PORT_TLS: int 170 | Desired port for the encrypted server to listen to. 171 | FTP_CERTFILE: str 172 | Path to the certificate used by the encrypted server. 173 | 174 | """ 175 | 176 | def __init__(self, use_TLS=False): 177 | env_dict = get_env_dict(use_TLS=use_TLS) 178 | self._server = SimpleFTPServer(use_TLS=use_TLS, **env_dict) 179 | 180 | @property 181 | def username(self): 182 | """ 183 | Name of the registered user. 184 | """ 185 | return self._server.username 186 | 187 | @property 188 | def password(self): 189 | """ 190 | Password of the registered user. 191 | """ 192 | return self._server.password 193 | 194 | @property 195 | def server_port(self): 196 | """ 197 | Port the server is running on. 198 | """ 199 | return self._server._ftp_port 200 | 201 | @property 202 | def server_home(self): 203 | """ 204 | Local path to FTP home for the registered user. 205 | """ 206 | return self._server._ftp_home 207 | 208 | @property 209 | def anon_root(self): 210 | """ 211 | Local path to FTP home for the anonymous user. 212 | """ 213 | return self._server._anon_root 214 | 215 | @property 216 | def uses_TLS(self): 217 | """ 218 | Weather or not the server uses TLS/SSL encryption. 219 | """ 220 | return self._server._uses_TLS 221 | 222 | @property 223 | def cert_path(self): 224 | """ 225 | Path to the used certificate File. 226 | """ 227 | return self._server._cert_path 228 | 229 | def _option_validator( 230 | valid_var_overwrite=None, # pylint: disable=no-self-argument 231 | strict_type_check=True, 232 | dev_mode=False, 233 | debug=False, 234 | ): 235 | """ 236 | Development helperfunction to raise appropriate Error if a methods arg/kwarg 237 | is of wrong type or value. 238 | 239 | If a arg/kwarg isn't in the default values defined in `valid_var_list`, 240 | add it to `valid_var_list` or use the option `valid_var_overwrite`, 241 | if it varies just in that case. 242 | 243 | 244 | Parameters 245 | ---------- 246 | valid_var_overwrite: dict, iterable of dicts: default None 247 | This is used if in a special case, an args/kwargs value/type varies 248 | from the one defines in `valid_var_list` 249 | 250 | strict_type_check: bool: default True 251 | Weather or not to use strict Type checking 252 | 253 | dev_mode: bool: default False 254 | Weather or not to give warning if arguments are missing `valid_var_list` 255 | 256 | Raises 257 | ------ 258 | TypeError 259 | If any of the checked args/kwargs has a not supported type 260 | 261 | ValueError 262 | If any of the checked args/kwargs has a not supported value 263 | """ 264 | 265 | def inner_decorator(f): 266 | """ 267 | Parameters 268 | ---------- 269 | f: function, method 270 | """ 271 | # this is needed so Sphinx will find the proper docstrings and signatures 272 | @wraps(f) 273 | def wrapper(self, *args, **kwargs): 274 | valid_var_list = { 275 | "style": { 276 | "valid_values": ["rel_path", "url"], 277 | "valid_types": [str], 278 | }, 279 | "anon": {"valid_types": [bool]}, 280 | "rel_file_path": {"valid_types": [str]}, 281 | "read_mode": {"valid_values": ["r", "rb"], "valid_types": [str]}, 282 | "overwrite": {"valid_types": [bool]}, 283 | "return_paths": { 284 | "valid_values": ["all", "input", "new"], 285 | "valid_types": [str], 286 | }, 287 | "return_content": {"valid_types": [bool]}, 288 | } 289 | # generate a named function args dict {"arg_name": "args_value"} 290 | # the name of the first argument needs to be skipped since it is always self 291 | func_locals = dict(**dict(zip(f.__code__.co_varnames[1:], args))) 292 | func_locals.update(kwargs) 293 | if debug: 294 | heading = f.__name__.upper() 295 | msg_line = [] 296 | for key in sorted(func_locals): 297 | msg_line.append(f"'{key}': {func_locals[key]}") 298 | msg = "\n".join(msg_line) 299 | pretty_logger(heading, "FUNC_LOCALS\n" + msg + "\n\n") 300 | try: 301 | sys.excepthook = _excepthook 302 | arg_validator( 303 | func_locals, 304 | valid_var_list, 305 | valid_var_overwrite, 306 | strict_type_check=strict_type_check, 307 | dev_mode=dev_mode, 308 | implementation_func_name=f.__name__, 309 | ) 310 | except (ValueError, TypeError) as e: 311 | sys.excepthook = arg_validator_excepthook 312 | raise e 313 | 314 | return f(self, *args, **kwargs) 315 | 316 | return wrapper 317 | 318 | return inner_decorator 319 | 320 | @_option_validator(debug=True) 321 | def __test_option_validator_logging__(self, a, b=3): 322 | """ 323 | This method is only implemented for the purpose 324 | of testing the logging output of _option_validator. 325 | Since decorators aren't inherited testing of that behaviour 326 | would else be more complicated 327 | 328 | Parameters 329 | ---------- 330 | a: any 331 | b: any, default 3 332 | """ 333 | 334 | def reset_tmp_dirs(self): 335 | """ 336 | Clears all temp files generated on the FTP server. 337 | This method is implemented to have more control over 338 | the module scoped ftp server. 339 | 340 | Examples 341 | -------- 342 | 343 | filesystem before: 344 | 345 | .. code:: 346 | 347 | filesystem 348 | +---server_home 349 | | +---test_file1 350 | | +---test_folder 351 | | +---test_file2 352 | | 353 | +---anon_root 354 | +---test_file3 355 | +---test_folder 356 | +---test_file4 357 | 358 | >>> ftpserver.reset_tmp_dirs() 359 | 360 | filesystem after: 361 | 362 | .. code:: 363 | 364 | filesystem 365 | +---server_home 366 | | 367 | +---anon_root 368 | """ 369 | self._server.reset_tmp_dirs() 370 | 371 | @_option_validator( 372 | valid_var_overwrite={ 373 | "style": {"valid_values": ["dict", "url"], "valid_types": [str]} 374 | } 375 | ) 376 | def get_login_data(self, style="dict", anon=False): 377 | """ 378 | Returns the login data as dict or url. 379 | What the returned value looks like is depending on `style` and 380 | the anonymous user or registered user depending `anon`. 381 | 382 | Parameters 383 | ---------- 384 | style: {'dict', 'url'}, default 'dict' 385 | 'dict': 386 | returns a dict with keys `host`, `port`, `user` 387 | and `passwd` or only `host` and `port` 388 | 389 | 'url': 390 | returns a url containing the the login data 391 | 392 | anon: bool 393 | True: 394 | returns the login data for the anonymous user 395 | 396 | False: 397 | returns the login data for the registered user 398 | 399 | Returns 400 | ------- 401 | login_data: dict, str 402 | Login data as dict or url, depending on the value of `style`. 403 | 404 | Raises 405 | ------ 406 | TypeError 407 | If `style` is not a ``str`` 408 | TypeError 409 | If `anon` is not a ``bool`` 410 | 411 | ValueError 412 | If the value of `style` is not 'dict' or 'url' 413 | 414 | Examples 415 | -------- 416 | 417 | >>> ftpserver.get_login_data() 418 | {"host": "localhost", "port": 8888, "user": "fakeusername", 419 | "passwd": "qweqwe"} 420 | 421 | >>> ftpserver.get_login_data(anon=True) 422 | {"host": "localhost", "port": 8888} 423 | 424 | >>> ftpserver.get_login_data(style="url") 425 | ftp://fakeusername:qweqwe@localhost:8888 426 | 427 | >>> ftpserver.get_login_data(style="url", anon=True) 428 | ftp://localhost:8888 429 | 430 | """ 431 | if style == "dict": 432 | login_dict = {"host": "localhost", "port": self.server_port} 433 | if not anon: # pragma: no branch 434 | login_dict["user"] = self.username 435 | login_dict["passwd"] = self.password 436 | return login_dict 437 | # even so only 'dict' and 'url' are supported values 438 | # here else is used for a better branch coverage 439 | else: 440 | host = "localhost:" + str(self.server_port) 441 | if self.uses_TLS: 442 | ftp_prefix = "ftpes" 443 | else: 444 | ftp_prefix = "ftp" 445 | if anon: 446 | return ftp_prefix + "://" + host 447 | else: 448 | return ( 449 | ftp_prefix 450 | + "://" 451 | + self.username 452 | + ":" 453 | + self.password 454 | + "@" 455 | + host 456 | ) 457 | 458 | @_option_validator() 459 | def format_file_path(self, rel_file_path, style="rel_path", anon=False): 460 | """ 461 | Formats the relative path to as relative path or url. 462 | Relative paths are relative to the server_home/anon_root, which can be used by a 463 | FTP client. 464 | Urls can be used by a browser/downloader. 465 | This method works, weather the file exists or not. 466 | 467 | Notes 468 | ----- 469 | Even so taking a relative path and returning a relative path may 470 | seam pointless, this is needed to prevent errors with Windows path 471 | formatting (``\\`` instead of ``/``). 472 | 473 | Parameters 474 | ---------- 475 | rel_file_path: str 476 | Relative filepath to server_home/anon_root depending on the value of anon. 477 | 478 | style: {'rel_path', 'url'}, default 'rel_path' 479 | 'rel_path': 480 | path relative to server_home/anon_root is returned. 481 | 482 | 'url': 483 | url to the file is returned. 484 | 485 | anon: bool 486 | True: 487 | return the filepaths/url of file in anon_root 488 | 489 | False: 490 | return the filepaths/url of file in server_home 491 | 492 | Returns 493 | ------- 494 | file_path: str 495 | Relative path or url depending on the value of style 496 | 497 | Raises 498 | ------ 499 | TypeError 500 | If `style` is not a ``str`` 501 | TypeError 502 | If `anon` is not a ``bool`` 503 | 504 | ValueError 505 | If the value of `style` is not 'rel_path' or 'url' 506 | 507 | Examples 508 | -------- 509 | 510 | >>> ftpserver.format_file_path("test_folder\\test_file", style="rel_path", anon=False)) 511 | test_folder/test_file 512 | 513 | >>> ftpserver.format_file_path("test_folder/test_file", style="rel_path", anon=False)) 514 | test_folder/test_file 515 | 516 | >>> ftpserver.format_file_path("test_folder/test_file", style="url", anon=False)) 517 | ftp://fakeusername:qweqwe@localhost:8888/test_folder/test_file 518 | 519 | >>> ftpserver.format_file_path("test_folder/test_file", style="url", anon=True)) 520 | ftp://localhost:8888/test_folder/test_file 521 | 522 | See Also 523 | -------- 524 | get_local_base_path 525 | get_file_paths 526 | 527 | """ 528 | # the replace is needed for windows systems 529 | rel_file_path = rel_file_path.replace("\\", "/") 530 | if style == "rel_path": 531 | return rel_file_path 532 | # even so only 'rel_path' and 'url' are supported values 533 | # here else is used for a better branch coverage 534 | else: 535 | base_url = self.get_login_data(style="url", anon=anon) 536 | return base_url + "/" + rel_file_path 537 | 538 | @_option_validator() 539 | def get_local_base_path(self, anon=False): 540 | """ 541 | Returns the basepath on the local file system. 542 | Depending on anon the basepath is for the registered 543 | or anonymous user. 544 | 545 | Parameters 546 | ---------- 547 | anon: bool, default False 548 | 549 | anon: bool 550 | True: 551 | returns the local path to anon_root 552 | 553 | False: 554 | returns the local path to server_home 555 | 556 | Returns 557 | ------- 558 | base_path: str 559 | Basepath on the local file system. 560 | 561 | Raises 562 | ------ 563 | TypeError 564 | If `anon` is not a ``bool`` 565 | 566 | Examples 567 | -------- 568 | 569 | >>> ftpserver.get_local_base_path(anon=False)) 570 | /tmp/ftp_home_1rg7_i 571 | 572 | >>> ftpserver.get_local_base_path(anon=True)) 573 | /tmp/anon_root_m6fknmyx 574 | """ 575 | if anon: 576 | base_path = self.anon_root 577 | else: 578 | base_path = self.server_home 579 | return base_path 580 | 581 | @_option_validator() 582 | def get_file_paths(self, style="rel_path", anon=False): 583 | """ 584 | Yields the paths of all files server_home/anon_root, in the given `style`. 585 | 586 | Parameters 587 | ---------- 588 | style: {'rel_path', 'url'}, default 'rel_path' 589 | 'rel_path': 590 | path relative to server_home/anon_root is returned. 591 | 592 | 'url': 593 | url to the file is returned. 594 | 595 | anon: bool 596 | True: 597 | filepaths/urls of all files in anon_root is returned. 598 | 599 | False: 600 | filepaths/urls of all files in server_home is returned. 601 | 602 | Yields 603 | ------ 604 | file_path: str 605 | Generator of all filepaths in the server_home/anon_root 606 | 607 | Raises 608 | ------ 609 | TypeError 610 | If `style` is not a ``str`` 611 | TypeError 612 | If `anon` is not a ``bool`` 613 | 614 | ValueError 615 | If the value of `style` is not 'rel_path' or 'url' 616 | 617 | Examples 618 | -------- 619 | 620 | Assuming a file structure as follows. 621 | 622 | .. code:: 623 | 624 | filesystem 625 | +---server_home 626 | | +---test_file1 627 | | +---test_folder 628 | | +---test_file2 629 | | 630 | +---anon_root 631 | +---test_file3 632 | +---test_folder 633 | +---test_file4 634 | 635 | 636 | >>> list(ftpserver.get_file_paths(style="rel_path", anon=False)) 637 | ["test_file1", "test_folder/test_file2"] 638 | 639 | >>> list(ftpserver.get_file_paths(style="rel_path", anon=True)) 640 | ["test_file3", "test_folder/test_file4"] 641 | 642 | 643 | 644 | >>> list(ftpserver.get_file_paths(style="url", anon=False)) 645 | ["ftp://fakeusername:qweqwe@localhost:8888/test_file1", 646 | "ftp://fakeusername:qweqwe@localhost:8888/test_folder/test_file2"] 647 | 648 | >>> list(ftpserver.get_file_paths(style="url", anon=True)) 649 | ["ftp://localhost:8888/test_file3", "ftp://localhost:8888/test_folder/test_file4"] 650 | 651 | """ 652 | base_path = self.get_local_base_path(anon=anon) 653 | for root, _, files in os.walk(base_path): 654 | for file in files: 655 | rel_file_path = os.path.relpath(os.path.join(root, file), base_path) 656 | yield self.format_file_path(rel_file_path, style, anon) 657 | 658 | @_option_validator( 659 | valid_var_overwrite={ 660 | "rel_file_paths": {"valid_types": [type(None), str, Iterable]} 661 | }, 662 | strict_type_check=False, 663 | ) 664 | # if you use dev_mode=True here you will get a warning that `rel_file_paths`, isn't in 665 | # `valid_var_list` this is on purpose, since `rel_file_paths` gets checked with 666 | # `strict_type_check=False` to be able to check `collections.abc.Iterable` 667 | @_option_validator(dev_mode=False) 668 | def get_file_contents( 669 | self, rel_file_paths=None, style="rel_path", anon=False, read_mode="r" 670 | ): 671 | """ 672 | Yields dicts containing the `path` and `content` of files on the FTP server. 673 | 674 | Parameters 675 | ---------- 676 | rel_file_paths: str, list of str, None, default None 677 | None: 678 | The content of all files on the server will be retrieved. 679 | 680 | str or list of str: 681 | Only the content of those files will be retrieved. 682 | 683 | style: {'rel_path', 'url'}, default 'rel_path' 684 | 'rel_path': 685 | Path relative to server_home/anon_root is returned. 686 | 687 | 'url': 688 | A url to the file is returned. 689 | 690 | anon: bool 691 | True: 692 | return the filepaths/url of files in anon_root 693 | 694 | False: 695 | return the filepaths/url of files in in server_home 696 | 697 | read_mode: {'r', 'rb'}, default 'r' 698 | Mode in which files should be read (see ``open("filepath", read_mode)`` ) 699 | 700 | Yields 701 | ------ 702 | content_dict: dict 703 | Dict containing the file `path` as relpath or url (see `style`) and 704 | the `content` of the file as string or bytes (see `read_mode`) 705 | 706 | Raises 707 | ------ 708 | TypeError 709 | If `rel_file_paths` is not ``None``, a ``str`` or an ``iterable`` 710 | TypeError 711 | If `style` is not a ``str`` 712 | TypeError 713 | If `anon` is not a ``bool`` 714 | TypeError 715 | If `read_mode` is not a ``str`` 716 | 717 | ValueError 718 | If the value of `rel_file_paths` or its items are not valid filepaths 719 | ValueError 720 | If the value of `style` is not 'rel_path' or 'url' 721 | ValueError 722 | If the value of `read_mode` is not 'r' or 'rb' 723 | 724 | Examples 725 | -------- 726 | 727 | Assuming a file structure as follows. 728 | 729 | .. code:: 730 | 731 | filesystem 732 | +---server_home 733 | +---test_file1.txt 734 | +---test_folder 735 | +---test_file2.zip 736 | 737 | 738 | >>> list(ftpserver.get_file_contents()) 739 | [{"path": "test_file1.txt", "content": "test text"}, 740 | {"path": "test_folder/test_file2.txt", "content": "test text2"}] 741 | 742 | >>> list(ftpserver.get_file_contents("test_file1.txt")) 743 | [{"path": "test_file1.txt", "content": "test text"}] 744 | 745 | >>> list(ftpserver.get_file_contents("test_file1.txt", style="url")) 746 | [{"path": "ftp://fakeusername:qweqwe@localhost:8888/test_file1.txt", 747 | "content": "test text"}] 748 | 749 | >>> list(ftpserver.get_file_contents(["test_file1.txt", "test_folder/test_file2.zip"], 750 | ... read_mode="rb")) 751 | [{"path": "test_file1.txt", "content": b"test text"}, 752 | {"path": "test_folder/test_file2.zip", "content": b'PK\\x03\\x04\\x14\\x00\\x00...'}] 753 | 754 | 755 | See Also 756 | -------- 757 | get_file_paths 758 | put_files 759 | """ 760 | base_path = self.get_local_base_path(anon=anon) 761 | if not rel_file_paths: 762 | rel_file_paths = self.get_file_paths(style=style, anon=anon) 763 | if isinstance(rel_file_paths, str) or not isinstance(rel_file_paths, Iterable): 764 | rel_file_paths = [rel_file_paths] 765 | for rel_file_path in rel_file_paths: 766 | if "ftp://" in rel_file_path: 767 | base_url = self.get_login_data(style="url", anon=anon) 768 | rel_file_path = os.path.relpath(rel_file_path, base_url) 769 | # the os.path.abspath is so windows doesn't mess up with \\ in paths 770 | abs_path = os.path.abspath(os.path.join(base_path, rel_file_path)) 771 | if os.path.isfile(abs_path): 772 | rel_file_path = self.format_file_path( 773 | rel_file_path=rel_file_path, style=style, anon=anon 774 | ) 775 | with open(abs_path, read_mode) as f: 776 | yield {"path": rel_file_path, "content": f.read()} 777 | else: 778 | raise ValueError( 779 | rel_file_path + " is not a valid relative file path or url." 780 | ) 781 | 782 | @_option_validator() 783 | def put_files( 784 | self, 785 | files_on_local, 786 | style="rel_path", 787 | anon=False, 788 | overwrite=False, 789 | return_paths="input", 790 | return_content=False, 791 | read_mode="r", 792 | ): 793 | """ 794 | Copies the files defined in `files_on_local` to the sever. 795 | After 'uploading' the files it returns a list of paths or 796 | content_dicts depending on `return_content` 797 | 798 | Parameters 799 | ---------- 800 | files_on_local: str, dict, list of str/dict, iterable of str/dict 801 | Path/-s to the local file/-s which should be copied to the server. 802 | 803 | str/list of str: 804 | all files will be copied to the chosen root. 805 | 806 | dict/list of dict: 807 | files_on_local["src"]: 808 | gives the local file path and 809 | 810 | files_on_local["dest"]: 811 | gives the relative path the file on the server. 812 | 813 | style: {'rel_path', 'url'}, default 'rel_path' 814 | 'rel_path': 815 | path relative to server_home/anon_root is returned. 816 | 817 | 'url': 818 | url to the file is returned. 819 | 820 | anon: bool 821 | True: 822 | Use anon_root as basepath 823 | 824 | False: 825 | Use server_home as basepath 826 | 827 | overwrite: bool, default False 828 | True: 829 | overwrites file without warning 830 | 831 | False: 832 | warns the user if a file exists and doesn't overwrite it 833 | 834 | return_paths: {'all', 'input', 'new'}, default 'input' 835 | 'all': 836 | Return all files in the server_home/anon_root. 837 | 838 | 'input': 839 | Return files in the server_home/anon_root, 840 | which were added by put_files. 841 | 842 | 'new': 843 | Return only changed files in the server_home/anon_root, 844 | which were added by put_files. 845 | 846 | return_content: bool, default False 847 | False: 848 | Elements of the iterable to be returned will consist of only 849 | the paths (str). 850 | 851 | True: 852 | Elements of the iterable to be returned will consist of content_dicts. 853 | 854 | read_mode: {'r', 'rb'}, default 'r' 855 | This only applies if `return_content` is True. 856 | Mode in which files should be read (see ``open("filepath", read_mode)`` ) 857 | 858 | 859 | Returns 860 | ------- 861 | file_list: list 862 | List of filepaths/content dicts in server_home/anon_root 863 | 864 | Raises 865 | ------ 866 | TypeError 867 | If `files_on_local` is not a ``str``, ``dict`` or ``iterable of str/dict`` 868 | TypeError 869 | If `style` is not a ``str`` 870 | TypeError 871 | If `anon` is not a ``bool`` 872 | TypeError 873 | If `overwrite` is not a ``bool`` 874 | TypeError 875 | If `return_paths` is not a ``str`` 876 | TypeError 877 | If `return_content` is not a ``bool`` 878 | TypeError 879 | If `read_mode` is not a ``str`` 880 | 881 | ValueError 882 | If `files_on_local` is/contains an invalid filepath. 883 | ValueError 884 | If the value of `style` is not 'rel_path' or 'url' 885 | ValueError 886 | If the value of `return_paths` is not 'all', 'input' or 'new' 887 | ValueError 888 | If the value of `read_mode` is not 'r' or 'rb' 889 | 890 | KeyError 891 | If dict or list of dicts is used for `files_on_local` and the dict is 892 | missing the keys 'src' and 'dest'. 893 | 894 | Examples 895 | -------- 896 | 897 | >>> ftpserver.put_files("test_folder/test_file", style="rel_path", anon=False) 898 | ["test_file"] 899 | 900 | >>> ftpserver.put_files("test_folder/test_file", style="url", anon=False) 901 | ["ftp://fakeusername:qweqwe@localhost:8888/test_file"] 902 | 903 | >>> ftpserver.put_files("test_folder/test_file", style="url", anon=True) 904 | ["ftp://localhost:8888/test_file"] 905 | 906 | >>> ftpserver.put_files({"src": "test_folder/test_file", 907 | ... "dest": "remote_folder/uploaded_file"}, 908 | ... style="url", anon=True) 909 | ["ftp://localhost:8888/remote_folder/uploaded_file"] 910 | 911 | >>> ftpserver.put_files("test_folder/test_file", return_content=True) 912 | [{"path": "test_file", "content": "some text in test_file"}] 913 | 914 | >>> ftpserver.put_files("test_file.zip", return_content=True, read_mode="rb") 915 | [{"path": "test_file.zip", "content": b'PK\\x03\\x04\\x14\\x00\\x00...'}] 916 | 917 | >>> ftpserver.put_files("test_file", return_paths="new") 918 | UserWarning: test_file does already exist and won't be overwritten. 919 | Set `overwrite` to True to overwrite it anyway. 920 | [] 921 | 922 | >>> ftpserver.put_files("test_file", return_paths="new", overwrite=True) 923 | ["test_file"] 924 | 925 | >>> ftpserver.put_files("test_file3", return_paths="all") 926 | ["test_file", "remote_folder/uploaded_file", "test_file.zip"] 927 | 928 | See Also 929 | -------- 930 | get_file_contents 931 | get_file_paths 932 | """ 933 | 934 | is_str_or_dict = isinstance(files_on_local, (str, dict)) 935 | if is_str_or_dict or not isinstance(files_on_local, Iterable): 936 | files_on_local = [files_on_local] 937 | 938 | base_path = self.get_local_base_path(anon=anon) 939 | file_list = [] 940 | 941 | def append_file_path(file_path): 942 | """ 943 | Helperfunction to reduce code overhead 944 | """ 945 | rel_file_path = os.path.relpath(file_path, base_path) 946 | rel_file_path = self.format_file_path(rel_file_path, style=style, anon=anon) 947 | file_list.append(rel_file_path) 948 | 949 | for file_path_local in files_on_local: 950 | # implementation if a str path is used 951 | if isinstance(file_path_local, str): 952 | 953 | dirs, filename = os.path.split(file_path_local) 954 | file_path = os.path.abspath(os.path.join(base_path, filename)) 955 | if not os.path.isfile(file_path_local): 956 | raise ValueError( 957 | file_path_local + " is not a valid file path, " 958 | "to an actual file." 959 | ) 960 | if os.path.isfile(file_path) and not overwrite: 961 | warnings.warn( 962 | UserWarning( 963 | file_path + " does already exist and won't be overwritten. " 964 | "Set `overwrite` to True to overwrite it anyway." 965 | ) 966 | ) 967 | else: 968 | shutil.copyfile(file_path_local, file_path) 969 | if return_paths == "new": 970 | append_file_path(file_path) 971 | if return_paths == "input": 972 | append_file_path(file_path) 973 | 974 | # implementation if a dict is used 975 | elif isinstance(file_path_local, dict): 976 | if "src" in file_path_local and "dest" in file_path_local: 977 | dirs, filename = os.path.split(file_path_local["dest"]) 978 | dir_path = os.path.abspath(os.path.join(base_path, dirs)) 979 | # strip is needed in case dirs is " " 980 | if dirs.strip() != "" and not os.path.isdir(dir_path): 981 | os.makedirs(dir_path) 982 | file_path_local = file_path_local["src"] 983 | file_path = os.path.abspath( 984 | os.path.join(base_path, dir_path, filename) 985 | ) 986 | 987 | if not os.path.isfile(file_path_local): 988 | raise ValueError( 989 | file_path_local + " is not a valid file path, " 990 | "to an actual file." 991 | ) 992 | if os.path.isfile(file_path) and not overwrite: 993 | warnings.warn( 994 | UserWarning( 995 | file_path 996 | + " does already exist and won't be overwritten. " 997 | "Set `overwrite` to True to overwrite it anyway." 998 | ) 999 | ) 1000 | else: 1001 | # would have liked to use symlinks on posix to reduce copy overhead, 1002 | # but then you get permission errors since the file isn't in the 1003 | # users root dir (maybe some1 has an idea how to solve that :D ) 1004 | shutil.copyfile(file_path_local, file_path) 1005 | if return_paths == "new": 1006 | append_file_path(file_path) 1007 | if return_paths == "input": 1008 | append_file_path(file_path) 1009 | else: 1010 | raise KeyError( 1011 | "If dicts are used in `put_files`, the dicts " 1012 | "need to have the Keys `src` and `dest`. " 1013 | "The value of `src` needs to be a valid file path." 1014 | ) 1015 | 1016 | else: 1017 | raise TypeError( 1018 | "`files_on_local` has to be of type a str or dict " 1019 | "or iterable of str/dict." 1020 | ) 1021 | 1022 | # this method uses return instead of a yield, because else files 1023 | # won't be copied 1024 | # if the user wouldn't iterate over the values 1025 | if not return_paths == "all" and not return_content: 1026 | return file_list 1027 | elif not return_paths == "all": 1028 | return self.get_file_contents( 1029 | file_list, style=style, anon=anon, read_mode=read_mode 1030 | ) 1031 | elif not return_content: 1032 | return self.get_file_paths(style=style, anon=anon) 1033 | else: 1034 | return self.get_file_contents( 1035 | style=style, 1036 | anon=anon, 1037 | read_mode=read_mode) 1038 | 1039 | @_option_validator( 1040 | valid_var_overwrite={ 1041 | "style": 1042 | {"valid_values": ["path", "content"], "valid_types": [str]} 1043 | } 1044 | ) 1045 | def get_cert(self, style="path", read_mode="r"): 1046 | """ 1047 | Returns the path to the used certificate or 1048 | its content as string or bytes. 1049 | 1050 | Parameters 1051 | ---------- 1052 | style: {'path', 'content'}, default 'path' 1053 | List of filepaths/content dicts in server_home/anon_root 1054 | 1055 | read_mode: {'r', 'rb'}, default 'r' 1056 | This only applies if `style` is 'content'. 1057 | Mode in which files should be read (see 1058 | ``open("filepath", read_mode)`` ) 1059 | 1060 | Returns 1061 | ------- 1062 | cert: str 1063 | Path to or content of the used certificate 1064 | 1065 | Raises 1066 | ------ 1067 | TypeError 1068 | If `style` is not a ``str`` 1069 | TypeError 1070 | If `read_mode` is not a ``str`` 1071 | 1072 | ValueError 1073 | If the value of `style` is not 'path' or 'content' 1074 | ValueError 1075 | If the value of `read_mode` is not 'r' or 'rb' 1076 | 1077 | WrongFixtureError 1078 | If used on ``ftpserver`` fixture, instead of 1079 | ``ftpserver_TLS`` fixture. 1080 | 1081 | Examples 1082 | -------- 1083 | 1084 | >>> ftpserver_TLS.get_cert() 1085 | "/home/certs/TLS_cert.pem" 1086 | 1087 | >>> ftpserver_TLS.get_cert(style="content") 1088 | "-----BEGIN RSA PRIVATE KEY-----\\nMIICXw..." 1089 | 1090 | >>> ftpserver_TLS.get_cert(style="content", read_mode="rb") 1091 | b"-----BEGIN RSA PRIVATE KEY-----\\nMIICXw..." 1092 | 1093 | """ 1094 | if self.uses_TLS: 1095 | if style == "path": 1096 | return os.path.abspath(self._server._cert_path) 1097 | else: 1098 | with open(self.cert_path, read_mode) as certfile: 1099 | return certfile.read() 1100 | else: 1101 | raise WrongFixtureError( 1102 | "The fixture ftpserver isn't using TLS, and thus" 1103 | "has no certificate. Use ftpserver_TLS instead." 1104 | ) 1105 | 1106 | def stop(self): 1107 | """ 1108 | Stops the server, closes all the open ports and deletes all temp files. 1109 | This is especially useful if you want to test if your code behaves 1110 | gracefully, when the ftpserver isn't reachable. 1111 | 1112 | Warning 1113 | ------- 1114 | 1115 | If pytest-localftpserver is run in 'module' (default) or 'session' 1116 | scope, this should be the last test run using this fixture 1117 | (in the given test module or suite), 1118 | since the server can't be restarted. 1119 | 1120 | Examples 1121 | -------- 1122 | 1123 | >>> ftpserver.stop() 1124 | >>> your_code_connecting_to_the_ftp() 1125 | RuntimeError: Server is offline/ not reachable. 1126 | 1127 | """ 1128 | self._server.stop() 1129 | 1130 | def __del__(self): 1131 | self.stop() 1132 | 1133 | 1134 | class ThreadFTPServer(FunctionalityWrapper): 1135 | """ 1136 | Implementation of the server based on FunctionalityWrapper for 1137 | (Windows and OSX). 1138 | To learn about the functionality check out BaseMPFTPServer. 1139 | """ 1140 | 1141 | def __init__(self, use_TLS=False): 1142 | super().__init__(use_TLS=use_TLS) 1143 | # The server needs to run in a separate thread or it will block all 1144 | # tests 1145 | self.thread = threading.Thread(target=self._server.serve_forever) 1146 | # This is a must in order to clear used sockets 1147 | self.thread.daemon = True 1148 | self.thread.start() 1149 | 1150 | def stop(self): 1151 | super().stop() 1152 | self.thread.join() 1153 | 1154 | 1155 | class ProcessFTPServer(FunctionalityWrapper): 1156 | """ 1157 | Implementation of the server based on FunctionalityWrapper for 1158 | (Linux). 1159 | To learn about the functionality check out BaseMPFTPServer. 1160 | """ 1161 | 1162 | # Python 3.14 changed the non-macOS POSIX default to forkserver 1163 | # but the code in this module does not work with it 1164 | # See https://github.com/python/cpython/issues/125714 1165 | if multiprocessing.get_start_method() == 'forkserver': 1166 | _mp_context = multiprocessing.get_context(method='fork') 1167 | else: 1168 | _mp_context = multiprocessing.get_context() 1169 | 1170 | def __init__(self, use_TLS=False): 1171 | super().__init__(use_TLS=use_TLS) 1172 | # The server needs to run in a separate process or 1173 | # it will block all tests 1174 | self.process = self._mp_context.Process( 1175 | target=self._server.serve_forever) 1176 | # This is a must in order to clear used sockets 1177 | self.process.daemon = True 1178 | self.process.start() 1179 | 1180 | def stop(self): 1181 | super().stop() 1182 | self.process.terminate() 1183 | 1184 | 1185 | if sys.platform.startswith("linux"): 1186 | USE_PROCESS = True 1187 | PytestLocalFTPServer = ProcessFTPServer 1188 | else: 1189 | USE_PROCESS = False 1190 | PytestLocalFTPServer = ThreadFTPServer 1191 | --------------------------------------------------------------------------------