From ed140e52eb58ba7e4503d726fc6088b4f98a1b4e Mon Sep 17 00:00:00 2001 From: Fabrice Normandin Date: Mon, 27 Nov 2023 13:05:59 -0500 Subject: [PATCH] (only) add more unit tests (#76) * (only) add more unit tests Signed-off-by: Fabrice Normandin * Add nice test for transform-related methods Signed-off-by: Fabrice Normandin * Remove/simplify a few tests Signed-off-by: Fabrice Normandin * Remove duplicate tests Signed-off-by: Fabrice Normandin * Greatly improved the tests Signed-off-by: Fabrice Normandin * Mark tests that work/don't work with `-s` flag Signed-off-by: Fabrice Normandin * Also show result of .run in test Signed-off-by: Fabrice Normandin * Fix isort Signed-off-by: Fabrice Normandin * Fix syntaxerror in test_remote.py Signed-off-by: Fabrice Normandin * Remove f-string {val=} expression for py<3.9 Signed-off-by: Fabrice Normandin * Fix check for `ssh localhost` being setup Signed-off-by: Fabrice Normandin * Minor touchups Signed-off-by: Fabrice Normandin * Fix display of result string in regression files Signed-off-by: Fabrice Normandin * Also run pytest with `-s` flag in CI Signed-off-by: Fabrice Normandin --------- Signed-off-by: Fabrice Normandin Signed-off-by: Fabrice Normandin --- .github/workflows/build.yml | 5 +- poetry.lock | 316 +++++--- pyproject.toml | 6 +- tests/cli/common.py | 108 ++- tests/cli/test_commands.py | 61 +- tests/cli/test_local.py | 39 +- tests/cli/test_remote.py | 742 +++++++++++++++++- ...bash_args4_initial_transforms0_echo_OK_.md | 28 + ...mand_args2_initial_transforms0_echo_OK_.md | 29 + ...file_args3_initial_transforms0_echo_OK_.md | 28 + ...orms_args0_initial_transforms0_echo_OK_.md | 32 + ...wrap_args1_initial_transforms0_echo_OK_.md | 29 + .../test_srun_transform_persist_localhost_.md | 39 + 13 files changed, 1322 insertions(+), 140 deletions(-) create mode 100644 tests/cli/test_remote/test_remote_transform_methods_localhost_with_bash_args4_initial_transforms0_echo_OK_.md create mode 100644 tests/cli/test_remote/test_remote_transform_methods_localhost_with_precommand_args2_initial_transforms0_echo_OK_.md create mode 100644 tests/cli/test_remote/test_remote_transform_methods_localhost_with_profile_args3_initial_transforms0_echo_OK_.md create mode 100644 tests/cli/test_remote/test_remote_transform_methods_localhost_with_transforms_args0_initial_transforms0_echo_OK_.md create mode 100644 tests/cli/test_remote/test_remote_transform_methods_localhost_wrap_args1_initial_transforms0_echo_OK_.md create mode 100644 tests/cli/test_remote/test_srun_transform_persist_localhost_.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4ed7aa16..d65cc598 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,7 +22,7 @@ jobs: run: | python -m pip install --upgrade pip pip install poetry - poetry install + poetry install --with=dev - name: Check formatting with black if: ${{ matrix.python-version == '3.10' }} @@ -34,3 +34,6 @@ jobs: - name: Test with pytest run: poetry run pytest + + - name: Test with pytest (with -s flag) + run: poetry run pytest -s diff --git a/poetry.lock b/poetry.lock index 83f2c77f..c0b49d21 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -16,7 +15,6 @@ files = [ name = "ansicon" version = "1.89.0" description = "Python wrapper for loading Jason Hood's ANSICON" -category = "main" optional = false python-versions = "*" files = [ @@ -28,7 +26,6 @@ files = [ name = "attrs" version = "22.2.0" description = "Classes Without Boilerplate" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -47,7 +44,6 @@ tests-no-zope = ["cloudpickle", "cloudpickle", "hypothesis", "hypothesis", "mypy name = "babel" version = "2.11.0" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -62,7 +58,6 @@ pytz = ">=2015.7" name = "bcrypt" version = "4.0.1" description = "Modern password hashing for your software and your servers" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -97,7 +92,6 @@ typecheck = ["mypy"] name = "black" version = "23.1a1" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -134,7 +128,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "blessed" version = "1.19.1" description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." -category = "main" optional = false python-versions = ">=2.7" files = [ @@ -151,7 +144,6 @@ wcwidth = ">=0.1.4" name = "certifi" version = "2022.12.7" description = "Python package for providing Mozilla's CA Bundle." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -163,7 +155,6 @@ files = [ name = "cffi" version = "1.15.1" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false python-versions = "*" files = [ @@ -240,7 +231,6 @@ pycparser = "*" name = "charset-normalizer" version = "3.0.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" optional = false python-versions = "*" files = [ @@ -338,7 +328,6 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -354,7 +343,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -366,7 +354,6 @@ files = [ name = "coverage" version = "5.5" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" files = [ @@ -432,53 +419,81 @@ toml = ["toml"] [[package]] name = "cryptography" -version = "39.0.0" +version = "41.0.4" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "cryptography-39.0.0-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52a1a6f81e738d07f43dab57831c29e57d21c81a942f4602fac7ee21b27f288"}, - {file = "cryptography-39.0.0-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:80ee674c08aaef194bc4627b7f2956e5ba7ef29c3cc3ca488cf15854838a8f72"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:887cbc1ea60786e534b00ba8b04d1095f4272d380ebd5f7a7eb4cc274710fad9"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f97109336df5c178ee7c9c711b264c502b905c2d2a29ace99ed761533a3460f"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a6915075c6d3a5e1215eab5d99bcec0da26036ff2102a1038401d6ef5bef25b"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:76c24dd4fd196a80f9f2f5405a778a8ca132f16b10af113474005635fe7e066c"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bae6c7f4a36a25291b619ad064a30a07110a805d08dc89984f4f441f6c1f3f96"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:875aea1039d78557c7c6b4db2fe0e9d2413439f4676310a5f269dd342ca7a717"}, - {file = "cryptography-39.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f6c0db08d81ead9576c4d94bbb27aed8d7a430fa27890f39084c2d0e2ec6b0df"}, - {file = "cryptography-39.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f3ed2d864a2fa1666e749fe52fb8e23d8e06b8012e8bd8147c73797c506e86f1"}, - {file = "cryptography-39.0.0-cp36-abi3-win32.whl", hash = "sha256:f671c1bb0d6088e94d61d80c606d65baacc0d374e67bf895148883461cd848de"}, - {file = "cryptography-39.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:e324de6972b151f99dc078defe8fb1b0a82c6498e37bff335f5bc6b1e3ab5a1e"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:754978da4d0457e7ca176f58c57b1f9de6556591c19b25b8bcce3c77d314f5eb"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee1fd0de9851ff32dbbb9362a4d833b579b4a6cc96883e8e6d2ff2a6bc7104f"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:fec8b932f51ae245121c4671b4bbc030880f363354b2f0e0bd1366017d891458"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:407cec680e811b4fc829de966f88a7c62a596faa250fc1a4b520a0355b9bc190"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7dacfdeee048814563eaaec7c4743c8aea529fe3dd53127313a792f0dadc1773"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ad04f413436b0781f20c52a661660f1e23bcd89a0e9bb1d6d20822d048cf2856"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50386acb40fbabbceeb2986332f0287f50f29ccf1497bae31cf5c3e7b4f4b34f"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:e5d71c5d5bd5b5c3eebcf7c5c2bb332d62ec68921a8c593bea8c394911a005ce"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:844ad4d7c3850081dffba91cdd91950038ee4ac525c575509a42d3fc806b83c8"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e0a05aee6a82d944f9b4edd6a001178787d1546ec7c6223ee9a848a7ade92e39"}, - {file = "cryptography-39.0.0.tar.gz", hash = "sha256:f964c7dcf7802d133e8dbd1565914fa0194f9d683d82411989889ecd701e8adf"}, + {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:80907d3faa55dc5434a16579952ac6da800935cd98d14dbd62f6f042c7f5e839"}, + {file = "cryptography-41.0.4-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35c00f637cd0b9d5b6c6bd11b6c3359194a8eba9c46d4e875a3660e3b400005f"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cecfefa17042941f94ab54f769c8ce0fe14beff2694e9ac684176a2535bf9714"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e40211b4923ba5a6dc9769eab704bdb3fbb58d56c5b336d30996c24fcf12aadb"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:23a25c09dfd0d9f28da2352503b23e086f8e78096b9fd585d1d14eca01613e13"}, + {file = "cryptography-41.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ed09183922d66c4ec5fdaa59b4d14e105c084dd0febd27452de8f6f74704143"}, + {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5a0f09cefded00e648a127048119f77bc2b2ec61e736660b5789e638f43cc397"}, + {file = "cryptography-41.0.4-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9eeb77214afae972a00dee47382d2591abe77bdae166bda672fb1e24702a3860"}, + {file = "cryptography-41.0.4-cp37-abi3-win32.whl", hash = "sha256:3b224890962a2d7b57cf5eeb16ccaafba6083f7b811829f00476309bce2fe0fd"}, + {file = "cryptography-41.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:c880eba5175f4307129784eca96f4e70b88e57aa3f680aeba3bab0e980b0f37d"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:004b6ccc95943f6a9ad3142cfabcc769d7ee38a3f60fb0dddbfb431f818c3a67"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:86defa8d248c3fa029da68ce61fe735432b047e32179883bdb1e79ed9bb8195e"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:37480760ae08065437e6573d14be973112c9e6dcaf5f11d00147ee74f37a3829"}, + {file = "cryptography-41.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b5f4dfe950ff0479f1f00eda09c18798d4f49b98f4e2006d644b3301682ebdca"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7e53db173370dea832190870e975a1e09c86a879b613948f09eb49324218c14d"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5b72205a360f3b6176485a333256b9bcd48700fc755fef51c8e7e67c4b63e3ac"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:93530900d14c37a46ce3d6c9e6fd35dbe5f5601bf6b3a5c325c7bffc030344d9"}, + {file = "cryptography-41.0.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3391bd8e6de35f6f1140e50aaeb3e2b3d6a9012536ca23ab0d9c35ec18c8a91"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0d9409894f495d465fe6fda92cb70e8323e9648af912d5b9141d616df40a87b8"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ac4f9ead4bbd0bc8ab2d318f97d85147167a488be0e08814a37eb2f439d5cf6"}, + {file = "cryptography-41.0.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:047c4603aeb4bbd8db2756e38f5b8bd7e94318c047cfe4efeb5d715e08b49311"}, + {file = "cryptography-41.0.4.tar.gz", hash = "sha256:7febc3094125fc126a7f6fb1f420d0da639f3f32cb15c8ff0dc3997c4549f51a"}, ] [package.dependencies] cffi = ">=1.12" [package.extras] -docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1,!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "ruff"] -sdist = ["setuptools-rust (>=0.11.4)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "deprecated" +version = "1.2.14" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, + {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] [[package]] name = "docutils" version = "0.17.1" description = "Docutils -- Python Documentation Utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -490,7 +505,6 @@ files = [ name = "exceptiongroup" version = "1.1.0" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -503,30 +517,28 @@ test = ["pytest (>=6)"] [[package]] name = "fabric" -version = "2.7.1" +version = "3.2.2" description = "High level SSH command execution" -category = "main" optional = false python-versions = "*" files = [ - {file = "fabric-2.7.1-py2.py3-none-any.whl", hash = "sha256:7610362318ef2d391cc65d4befb684393975d889ed5720f23499394ec0e136fa"}, - {file = "fabric-2.7.1.tar.gz", hash = "sha256:76f8fef59cf2061dbd849bbce4fe49bdd820884385004b0ca59136ac3db129e4"}, + {file = "fabric-3.2.2-py3-none-any.whl", hash = "sha256:91c47c0be68b14936c88b34da8a1f55e5710fd28397dac5d4ff2e21558113a6f"}, + {file = "fabric-3.2.2.tar.gz", hash = "sha256:8783ca42e3b0076f08b26901aac6b9d9b1f19c410074e7accfab902c184ff4a3"}, ] [package.dependencies] -invoke = ">=1.3,<2.0" +decorator = ">=5" +deprecated = ">=1.2" +invoke = ">=2.0" paramiko = ">=2.4" -pathlib2 = "*" [package.extras] -pytest = ["mock (>=2.0.0,<3.0)", "pytest (>=3.2.5,<4.0)"] -testing = ["mock (>=2.0.0,<3.0)"] +pytest = ["pytest (>=7)"] [[package]] name = "flake8" version = "6.0.0" description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" optional = false python-versions = ">=3.8.1" files = [ @@ -543,7 +555,6 @@ pyflakes = ">=3.0.0,<3.1.0" name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -555,7 +566,6 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -567,7 +577,6 @@ files = [ name = "importlib-metadata" version = "6.0.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -588,7 +597,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -598,21 +606,19 @@ files = [ [[package]] name = "invoke" -version = "1.7.3" +version = "2.2.0" description = "Pythonic task execution" -category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" files = [ - {file = "invoke-1.7.3-py3-none-any.whl", hash = "sha256:d9694a865764dd3fd91f25f7e9a97fb41666e822bbb00e670091e3f43933574d"}, - {file = "invoke-1.7.3.tar.gz", hash = "sha256:41b428342d466a82135d5ab37119685a989713742be46e42a3a399d685579314"}, + {file = "invoke-2.2.0-py3-none-any.whl", hash = "sha256:6ea924cc53d4f78e3d98bc436b08069a03077e6f85ad1ddaa8a116d7dad15820"}, + {file = "invoke-2.2.0.tar.gz", hash = "sha256:ee6cbb101af1a859c7fe84f2a264c059020b0cb7fe3535f9424300ab568f6bd5"}, ] [[package]] name = "isort" version = "5.11.4" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -630,7 +636,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "jinja2" version = "3.1.2" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -648,7 +653,6 @@ i18n = ["Babel (>=2.7)"] name = "jinxed" version = "1.2.0" description = "Jinxed Terminal Library" -category = "main" optional = false python-versions = "*" files = [ @@ -663,7 +667,6 @@ ansicon = {version = "*", markers = "platform_system == \"Windows\""} name = "markupsafe" version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -723,7 +726,6 @@ files = [ name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -735,7 +737,6 @@ files = [ name = "mypy-extensions" version = "0.4.3" description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" optional = false python-versions = "*" files = [ @@ -747,7 +748,6 @@ files = [ name = "packaging" version = "23.0" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -757,14 +757,13 @@ files = [ [[package]] name = "paramiko" -version = "3.0.0" +version = "3.3.1" description = "SSH2 protocol library" -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "paramiko-3.0.0-py3-none-any.whl", hash = "sha256:6bef55b882c9d130f8015b9a26f4bd93f710e90fe7478b9dcc810304e79b3cd8"}, - {file = "paramiko-3.0.0.tar.gz", hash = "sha256:fedc9b1dd43bc1d45f67f1ceca10bc336605427a46dcdf8dec6bfea3edf57965"}, + {file = "paramiko-3.3.1-py3-none-any.whl", hash = "sha256:b7bc5340a43de4287bbe22fe6de728aa2c22468b2a849615498dd944c2f275eb"}, + {file = "paramiko-3.3.1.tar.gz", hash = "sha256:6a3777a961ac86dbef375c5f5b8d50014a1a96d0fd7f054a43bc880134b0ff77"}, ] [package.dependencies] @@ -777,26 +776,10 @@ all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1 gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] invoke = ["invoke (>=2.0)"] -[[package]] -name = "pathlib2" -version = "2.3.7.post1" -description = "Object-oriented filesystem paths" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "pathlib2-2.3.7.post1-py2.py3-none-any.whl", hash = "sha256:5266a0fd000452f1b3467d782f079a4343c63aaa119221fbdc4e39577489ca5b"}, - {file = "pathlib2-2.3.7.post1.tar.gz", hash = "sha256:9fe0edad898b83c0c3e199c842b27ed216645d2e177757b2dd67384d4113c641"}, -] - -[package.dependencies] -six = "*" - [[package]] name = "pathspec" version = "0.11.0" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -808,7 +791,6 @@ files = [ name = "platformdirs" version = "2.6.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -827,7 +809,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest- name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -846,7 +827,6 @@ testing = ["pytest", "pytest-benchmark"] name = "prompt-toolkit" version = "3.0.36" description = "Library for building powerful interactive command lines in Python" -category = "main" optional = false python-versions = ">=3.6.2" files = [ @@ -861,7 +841,6 @@ wcwidth = "*" name = "pycodestyle" version = "2.10.0" description = "Python style guide checker" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -873,7 +852,6 @@ files = [ name = "pycparser" version = "2.21" description = "C parser in Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -885,7 +863,6 @@ files = [ name = "pyflakes" version = "3.0.1" description = "passive checker of Python programs" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -897,7 +874,6 @@ files = [ name = "pygments" version = "2.14.0" description = "Pygments is a syntax highlighting package written in Python." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -912,7 +888,6 @@ plugins = ["importlib-metadata"] name = "pynacl" version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -939,7 +914,6 @@ tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] name = "pytest" version = "7.2.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -964,7 +938,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2. name = "pytest-cov" version = "4.0.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -983,7 +956,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "pytest-datadir" version = "1.4.1" description = "pytest plugin for test data directories and files" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -994,11 +966,27 @@ files = [ [package.dependencies] pytest = ">=5.0" +[[package]] +name = "pytest-mock" +version = "3.11.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"}, + {file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pytest-regressions" version = "2.4.2" description = "Easy to use fixtures to write regression tests." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1017,11 +1005,24 @@ dev = ["matplotlib", "mypy", "numpy", "pandas", "pillow", "pre-commit", "restruc image = ["numpy", "pillow"] num = ["numpy", "pandas"] +[[package]] +name = "pytest-socket" +version = "0.6.0" +description = "Pytest Plugin to disable socket calls during tests" +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "pytest_socket-0.6.0-py3-none-any.whl", hash = "sha256:cca72f134ff01e0023c402e78d31b32e68da3efdf3493bf7788f8eba86a6824c"}, + {file = "pytest_socket-0.6.0.tar.gz", hash = "sha256:363c1d67228315d4fc7912f1aabfd570de29d0e3db6217d61db5728adacd7138"}, +] + +[package.dependencies] +pytest = ">=3.6.3" + [[package]] name = "pytz" version = "2022.7.1" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" files = [ @@ -1033,7 +1034,6 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1083,7 +1083,6 @@ files = [ name = "questionary" version = "1.10.0" description = "Python library to build pretty command line user prompts ⭐️" -category = "main" optional = false python-versions = ">=3.6,<4.0" files = [ @@ -1101,7 +1100,6 @@ docs = ["Sphinx (>=3.3,<4.0)", "sphinx-autobuild (>=2020.9.1,<2021.0.0)", "sphin name = "requests" version = "2.28.2" description = "Python HTTP for Humans." -category = "dev" optional = false python-versions = ">=3.7, <4" files = [ @@ -1123,7 +1121,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1135,7 +1132,6 @@ files = [ name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" optional = false python-versions = "*" files = [ @@ -1147,7 +1143,6 @@ files = [ name = "sphinx" version = "5.3.0" description = "Python documentation generator" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1183,7 +1178,6 @@ test = ["cython", "html5lib", "pytest (>=4.6)", "typed_ast"] name = "sphinx-rtd-theme" version = "1.1.1" description = "Read the Docs theme for Sphinx" -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" files = [ @@ -1202,7 +1196,6 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] name = "sphinxcontrib-applehelp" version = "1.0.2" description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1218,7 +1211,6 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1234,7 +1226,6 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1250,7 +1241,6 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1265,7 +1255,6 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1281,7 +1270,6 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1297,7 +1285,6 @@ test = ["pytest"] name = "sshconf" version = "0.2.5" description = "Lightweight SSH config library." -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -1312,7 +1299,6 @@ test = ["codecov", "pytest", "pytest-cov"] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -1324,7 +1310,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1336,7 +1321,6 @@ files = [ name = "typed-ast" version = "1.5.4" description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1370,7 +1354,6 @@ files = [ name = "typing-extensions" version = "4.7.1" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1382,7 +1365,6 @@ files = [ name = "urllib3" version = "1.26.14" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" files = [ @@ -1399,7 +1381,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] name = "wcwidth" version = "0.2.6" description = "Measures the displayed width of unicode strings in a terminal" -category = "main" optional = false python-versions = "*" files = [ @@ -1407,11 +1388,94 @@ files = [ {file = "wcwidth-0.2.6.tar.gz", hash = "sha256:a5220780a404dbe3353789870978e472cfe477761f06ee55077256e509b156d0"}, ] +[[package]] +name = "wrapt" +version = "1.15.0" +description = "Module for decorators, wrappers and monkey patching." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "wrapt-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1"}, + {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29"}, + {file = "wrapt-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2"}, + {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46"}, + {file = "wrapt-1.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c"}, + {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09"}, + {file = "wrapt-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079"}, + {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e"}, + {file = "wrapt-1.15.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a"}, + {file = "wrapt-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923"}, + {file = "wrapt-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee"}, + {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727"}, + {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7"}, + {file = "wrapt-1.15.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0"}, + {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec"}, + {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90"}, + {file = "wrapt-1.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975"}, + {file = "wrapt-1.15.0-cp310-cp310-win32.whl", hash = "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1"}, + {file = "wrapt-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e"}, + {file = "wrapt-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7"}, + {file = "wrapt-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72"}, + {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb"}, + {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e"}, + {file = "wrapt-1.15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c"}, + {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3"}, + {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92"}, + {file = "wrapt-1.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98"}, + {file = "wrapt-1.15.0-cp311-cp311-win32.whl", hash = "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416"}, + {file = "wrapt-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705"}, + {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29"}, + {file = "wrapt-1.15.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd"}, + {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb"}, + {file = "wrapt-1.15.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248"}, + {file = "wrapt-1.15.0-cp35-cp35m-win32.whl", hash = "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559"}, + {file = "wrapt-1.15.0-cp35-cp35m-win_amd64.whl", hash = "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639"}, + {file = "wrapt-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba"}, + {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752"}, + {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364"}, + {file = "wrapt-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475"}, + {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8"}, + {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418"}, + {file = "wrapt-1.15.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2"}, + {file = "wrapt-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1"}, + {file = "wrapt-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420"}, + {file = "wrapt-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317"}, + {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e"}, + {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e"}, + {file = "wrapt-1.15.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0"}, + {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019"}, + {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034"}, + {file = "wrapt-1.15.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653"}, + {file = "wrapt-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0"}, + {file = "wrapt-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e"}, + {file = "wrapt-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145"}, + {file = "wrapt-1.15.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f"}, + {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd"}, + {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b"}, + {file = "wrapt-1.15.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f"}, + {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6"}, + {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094"}, + {file = "wrapt-1.15.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7"}, + {file = "wrapt-1.15.0-cp38-cp38-win32.whl", hash = "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b"}, + {file = "wrapt-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1"}, + {file = "wrapt-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86"}, + {file = "wrapt-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c"}, + {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d"}, + {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc"}, + {file = "wrapt-1.15.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29"}, + {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a"}, + {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8"}, + {file = "wrapt-1.15.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9"}, + {file = "wrapt-1.15.0-cp39-cp39-win32.whl", hash = "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff"}, + {file = "wrapt-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6"}, + {file = "wrapt-1.15.0-py3-none-any.whl", hash = "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640"}, + {file = "wrapt-1.15.0.tar.gz", hash = "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a"}, +] + [[package]] name = "zipp" version = "3.11.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1426,4 +1490,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = "^3.7" -content-hash = "10c78393b0c62e656f0afe9522944a857b270a0e99c7cfaeaed56319119b71c3" +content-hash = "28ec877788fbb6ce3f6dc998e1f24690d7cf16ad0ab1b701004973e63f9c347c" diff --git a/pyproject.toml b/pyproject.toml index eeeb8c95..a6bdd433 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,8 @@ python = "^3.7" blessed = "^1.18.1" sshconf = "^0.2.2" questionary = "^1.10.0" -Fabric = "^2.7.0" typing-extensions = "^4.7.1" +fabric = "^3.2.2" [tool.poetry.dev-dependencies] black = ">= 21.8b0" @@ -33,6 +33,9 @@ mila = "milatools.cli.__main__:main" [tool.poetry.group.dev.dependencies] pytest = "^7.2.1" pytest-regressions = "^2.4.2" +fabric = {extras = ["testing"], version = "^3.2.2"} +pytest-mock = "^3.11.1" +pytest-socket = "^0.6.0" [tool.isort] multi_line_output = 3 @@ -42,6 +45,7 @@ combine_as_imports = true [tool.pytest.ini_options] addopts = "--doctest-modules" +markers = "--enable-internet: Allow some tests to run using real connections to the cluster." [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/cli/common.py b/tests/cli/common.py index 8d7bc800..786f4deb 100644 --- a/tests/cli/common.py +++ b/tests/cli/common.py @@ -1,4 +1,36 @@ +from __future__ import annotations + +import inspect +import sys +import typing from subprocess import CompletedProcess +from typing import Any, Callable + +import pytest +from pytest_regressions.file_regression import FileRegressionFixture +from typing_extensions import ParamSpec + +if typing.TYPE_CHECKING: + from typing_extensions import TypeGuard + + +P = ParamSpec("P") + + +requires_s_flag = pytest.mark.skipif( + "-s" not in sys.argv, + reason=( + "Seems to require reading from stdin? Works with the -s flag, but other " + "tests might not." + ), +) + + +requires_no_s_flag = pytest.mark.skipif( + "-s" in sys.argv, + reason="Passing pytest's -s flag makes this test fail.", +) + cmdtest = """=============== Captured stdout @@ -19,7 +51,14 @@ """ -def output_tester(func, capsys, file_regression): +def output_tester( + func: Callable[ + [], + tuple[str | CompletedProcess[str] | None, str | CompletedProcess[str] | None], + ], + capsys: pytest.CaptureFixture, + file_regression: FileRegressionFixture, +): out, err = None, None try: out, err = func() @@ -32,3 +71,70 @@ def output_tester(func, capsys, file_regression): file_regression.check( cmdtest.format(cout=captured.out, cerr=captured.err, out=out, err=err) ) + + +def function_call_string( + fn: Callable[P, Any], + *args: P.args, + **kwargs: P.kwargs, +) -> str: + """Returns a nice string representation of code that calls `fn(*args, **kwargs)`. + + This is used to show code snippets in the regression files generated by unit tests. + """ + + # Call `repr` on the arguments, except for lambdas, which are shown as their body. + args_str = [_lambda_to_str(v) if _is_lambda(v) else repr(v) for v in args] + kwargs_str = { + k: _lambda_to_str(v) if _is_lambda(v) else repr(v) for k, v in kwargs.items() + } + fn_str = fn.__name__ + + single_line = ( + fn_str + + "(" + + ", ".join(args_str) + + (", " if args_str and kwargs_str else "") + + ", ".join(f"{k}={v}" for k, v in kwargs_str.items()) + + ")" + ) + indent = 4 * " " + multi_line = ( + f"{fn_str}(\n" + + "\n".join(f"{indent}{arg}," for arg in args_str) + + ("\n" if args_str and kwargs_str else "") + + "\n".join(f"{indent}{key}={value}," for key, value in kwargs_str.items()) + + "\n)" + ) + + if len(single_line) < 80: + return single_line + return multi_line + + +def _is_lambda(v: Any) -> TypeGuard[Callable]: + """Returns whether the value is a lambda expression.""" + return ( + callable(v) + and isinstance(v, type(lambda _: _)) + and getattr(v, "__name__", None) == "" + ) + + +def _lambda_to_str(lambda_: Callable) -> str: + """Shows the body of the lambda instead of the default repr.""" + lambda_body = inspect.getsource(lambda_).strip() + # when putting lambdas in a list, like so: + # funcs = [ + # lambda x: x + 1, + # ] + # a trailing comma is returned by `inspect.getsource`, which we want to remove. + return _removesuffix(lambda_body, ",") + + +def _removesuffix(s: str, suffix: str) -> str: + """Backport of `str.removesuffix` for Python<3.9.""" + if s.endswith(suffix): + return s[: -len(suffix)] + else: + return s diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index fad45c33..35382335 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -1,18 +1,73 @@ import contextlib +import importlib import io import shlex +import subprocess +from subprocess import CompletedProcess +from unittest import mock import pytest +from fabric.testing.fixtures import Connection, MockRemote, remote # noqa +from pytest_mock import MockerFixture from pytest_regressions.file_regression import FileRegressionFixture +from typing_extensions import ParamSpec from milatools.cli.commands import main +from milatools.cli.local import Local + +from .common import requires_no_s_flag def _convert_argparse_output_to_pre_py311_format(output: str) -> str: - """Revert the slight change in the output format of argparse in python 3.11.""" + """Revert the slight change in the output of argparse in python 3.11.""" return output.replace("options:\n", "optional arguments:\n") +P = ParamSpec("P") + + +def reload_module(): + """Reload the module after mocking out functions. + + Need to reload the module because we have mocked some of the functions that are used + as default values in the methods of the class under test (e.g. `subprocess.run`). + The functions being in the signature of the methods makes it possible for us to + describe their our method signatures explicitly, but it means that we need to reload + the module after mocking them. + """ + global Local + import milatools.cli.local + + importlib.reload(milatools.cli.local) + Local = milatools.cli.local.Local + + +@pytest.fixture +def mock_subprocess_run(mocker: MockerFixture) -> mock.Mock: + mock_subprocess_run: mock.Mock = mocker.patch("subprocess.run") + # NOTE: Trying out https://stackoverflow.com/questions/25692440/mocking-a-subprocess-call-in-python + + reload_module() + return mock_subprocess_run + + +def test_check_passwordless( + mock_subprocess_run: mock.Mock, +): + # NOTE: Trying out https://stackoverflow.com/questions/25692440/mocking-a-subprocess-call-in-python + mock_subprocess_run.return_value = CompletedProcess(["echo OK"], 0, stdout="BOBOBO") + local = Local() + local.check_passwordless("mila") + + mock_subprocess_run.assert_called_once_with( + tuple(shlex.split("ssh -oPreferredAuthentications=publickey mila 'echo OK'")), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True, + ) + + +@requires_no_s_flag @pytest.mark.parametrize( "command", ["mila"] @@ -47,11 +102,11 @@ def test_help( ), contextlib.redirect_stderr(buf): with pytest.raises(SystemExit): main() - output: str = buf.getvalue() file_regression.check(_convert_argparse_output_to_pre_py311_format(output)) +@requires_no_s_flag @pytest.mark.parametrize( "command", [ @@ -86,7 +141,7 @@ def test_invalid_command_output( # *args, # marks=pytest.mark.skipif( # "GITHUB_ACTIONS" in os.environ, -# reason="We don't run this test on GitHub Actions for security reasons.", +# reason="We don't run this test on GitHub Actions.", # ), # ) diff --git a/tests/cli/test_local.py b/tests/cli/test_local.py index 77009236..90e34314 100644 --- a/tests/cli/test_local.py +++ b/tests/cli/test_local.py @@ -1,33 +1,55 @@ +from __future__ import annotations + from subprocess import PIPE import pytest +from pytest_regressions.file_regression import FileRegressionFixture from milatools.cli.local import CommandNotFoundError, Local -from .common import output_tester +from .common import output_tester, requires_no_s_flag _ECHO_CMD = ["echo", "--arg1", "val1", "--arg2=val2", "X"] _FAKE_CMD = ["FAKEcmd", "--arg1", "val1", "--arg2=val2", "X"] _FAIL_CODE_CMD = ["FAKEcode", "--arg1", "val1", "--arg2=val2", "X"] +@requires_no_s_flag @pytest.mark.parametrize("cmd", [_ECHO_CMD, _FAKE_CMD]) -def test_display(cmd, capsys, file_regression): +def test_display( + cmd: list[str], + capsys: pytest.CaptureFixture, + file_regression: FileRegressionFixture, +): output_tester(lambda: (Local().display(cmd), None), capsys, file_regression) @pytest.mark.parametrize("cmd", [_ECHO_CMD]) -def test_silent_get(cmd, capsys, file_regression): +def test_silent_get( + cmd: list[str], + capsys: pytest.CaptureFixture, + file_regression: FileRegressionFixture, +): output_tester(lambda: (Local().silent_get(*cmd), None), capsys, file_regression) +@requires_no_s_flag @pytest.mark.parametrize("cmd", [_ECHO_CMD]) -def test_get(cmd, capsys, file_regression): +def test_get( + cmd: list[str], + capsys: pytest.CaptureFixture, + file_regression: FileRegressionFixture, +): output_tester(lambda: (Local().get(*cmd), None), capsys, file_regression) +@requires_no_s_flag @pytest.mark.parametrize("cmd", [_ECHO_CMD, _FAKE_CMD, _FAIL_CODE_CMD]) -def test_run(cmd, capsys, file_regression): +def test_run( + cmd: list[str], + capsys: pytest.CaptureFixture, + file_regression: FileRegressionFixture, +): def func(): return Local().run(*cmd, capture_output=True), None @@ -47,8 +69,13 @@ def _catch_exc(): output_tester(func, capsys, file_regression) +@requires_no_s_flag @pytest.mark.parametrize("cmd", [_ECHO_CMD]) -def test_popen(cmd, capsys, file_regression): +def test_popen( + cmd: list[str], + capsys: pytest.CaptureFixture, + file_regression: FileRegressionFixture, +): output_tester( lambda: Local().popen(*cmd, stdout=PIPE, stderr=PIPE).communicate(), capsys, diff --git a/tests/cli/test_remote.py b/tests/cli/test_remote.py index a59ca28d..0548daa2 100644 --- a/tests/cli/test_remote.py +++ b/tests/cli/test_remote.py @@ -1,9 +1,747 @@ +"""Tests for the Remote and SlurmRemote classes.""" +from __future__ import annotations + +import shutil +import time +import typing +import unittest +import unittest.mock +from pathlib import Path +from typing import Callable, Generator, Iterable +from unittest.mock import Mock + +import invoke +import paramiko.ssh_exception import pytest +from fabric.connection import Connection +from pytest_regressions.file_regression import FileRegressionFixture +from typing_extensions import ParamSpec + +from milatools.cli.remote import ( + QueueIO, + Remote, + SlurmRemote, + get_first_node_name, +) +from milatools.cli.utils import T, shjoin + +from .common import function_call_string, requires_s_flag + +P = ParamSpec("P") + +passwordless_ssh_connection_to_localhost_is_setup = False + +try: + Connection("localhost").open() +except paramiko.ssh_exception.SSHException: + pass +else: + passwordless_ssh_connection_to_localhost_is_setup = True + + +@pytest.fixture( + scope="session", + params=[ + pytest.param( + "localhost", + marks=pytest.mark.skipif( + not passwordless_ssh_connection_to_localhost_is_setup, + reason="Passwordless ssh access to localhost needs to be setup.", + ), + ), + # TODO: Think about a smart way to enable this. Some tests won't work as-is. + # pytest.param( + # "mila", + # marks=pytest.mark.skipif( + # "-vvv" not in sys.argv, reason="Not testing using the Mila cluster." + # ), + # ), + ], +) +def host(request: pytest.FixtureRequest) -> str: + return request.param + + +@pytest.fixture(scope="session") +def connection(host: str) -> Generator[Connection, None, None]: + """Fixture that gives a Connection object that is reused by all tests.""" + with Connection(host) as connection: + yield connection + + +@pytest.fixture(scope="function") +def MockConnection( + monkeypatch: pytest.MonkeyPatch, connection: Connection, host: str +) -> Mock: + """Returns a Mock wrapping the `fabric.connection.Connection` class.""" + # The return value of the constructor will always be the shared `Connection` object. + MockConnection = Mock( + name="MockConnection", + wraps=Connection, + return_value=Mock( + name="mock_connection", + # Modify the repr so they show up nicely in the regression files and with + # consistent/reproducible names. + wraps=connection, + host=host, + __repr__=lambda _: f"Connection({repr(host)})", + ), + ) + # mock_connection.configure_mock( + # __repr__=lambda _: f"Connection({repr(host)})", + # ) + import milatools.cli.remote + + monkeypatch.setattr(milatools.cli.remote, Connection.__name__, MockConnection) + return MockConnection + + +@pytest.fixture(scope="function") +def mock_connection( + MockConnection: Mock, +) -> Mock: + """returns a Mock wrapping a real `Connection` instance. + + This Mock is used to check how the connection is used by `Remote` and `SlurmRemote`. + """ + mock_connection: Mock = MockConnection.return_value + # mock_connection.configure_mock( + # # Modify the repr so they show up nicely in the regression files and with + # # consistent/reproducible names. + # __repr__=lambda _: f"Connection({repr(host)})", + # ) + return mock_connection + + +@pytest.mark.parametrize("keepalive", [0, 123]) +def test_init( + keepalive: int, + host: str, + MockConnection: Mock, + mock_connection: Mock, +): + """This test shows the behaviour of __init__ to isolate it from other tests.""" + + # This should have called the `Connection` class with the host, which we patched in + # the fixture above. + r = Remote(host, keepalive=keepalive) + # The Remote should have created a Connection instance (which happens to be + # the mock_connection we made above). + MockConnection.assert_called_once_with(host) + assert r.connection is mock_connection + + # The connection's Transport is opened if a non-zero value is passed for `keepalive` + if keepalive: + assert len(mock_connection.method_calls) == 2 + mock_connection.open.assert_called_once() + mock_connection.transport.set_keepalive.assert_called_once_with(keepalive) + else: + assert not mock_connection.method_calls + mock_connection.open.assert_not_called() + mock_connection.transport.set_keepalive.assert_not_called() + + +@pytest.mark.parametrize("keepalive", [0, 123]) +def test_init_with_connection( + keepalive: int, + MockConnection: Mock, + mock_connection: Mock, +): + """This test shows the behaviour of __init__ to isolate it from other tests.""" + r = Remote(mock_connection.host, connection=mock_connection, keepalive=keepalive) + MockConnection.assert_not_called() + assert r.connection is mock_connection + # The connection is not opened, and the transport is also not opened. + assert not mock_connection.method_calls + mock_connection.open.assert_not_called() + mock_connection.transport.set_keepalive.assert_not_called() + + +# Note: We could actually run this for real also! +@requires_s_flag +@pytest.mark.parametrize("command_to_run", ["echo OK"]) +@pytest.mark.parametrize("initial_transforms", [[]]) +@pytest.mark.parametrize( + ("method", "args"), + [ + ( + Remote.with_transforms, + ( + lambda cmd: cmd.replace("OK", "NOT_OK"), + lambda cmd: f"echo 'command before' && {cmd}", + ), + ), + ( + Remote.wrap, + ("echo 'echo wrap' && {}",), + ), + ( + Remote.with_precommand, + ("echo 'echo precommand'",), + ), + # this need to be a file to source before running the command. + (Remote.with_profile, (".bashrc",)), + (Remote.with_bash, ()), + ], +) +def test_remote_transform_methods( + command_to_run: str, + initial_transforms: list[Callable[[str], str]], + method: Callable, + args: tuple, + host: str, + mock_connection: Connection | Mock, + file_regression: FileRegressionFixture, + capsys: pytest.CaptureFixture, +): + """Test the methods of `Remote` that modify the commands passed to `run` before it + gets passed to the connection and run on the server.""" + mock_connection = mock_connection + r = Remote( + host, + connection=mock_connection, + transforms=initial_transforms, + ) + # Call the method on the remote, which should return a new Remote. + modified_remote: Remote = method(r, *args) + assert modified_remote.hostname == r.hostname + assert modified_remote.connection is r.connection + + result = modified_remote.run(command_to_run) + + out_err = capsys.readouterr() + stdout = out_err.out + stderr = out_err.err + assert not stderr + + # TODO: Would also need to remove other color codes if there were any. + v = T.bold_cyan("@") + color_prefix, _, color_suffix = v.partition("@") + stdout = stdout.replace(color_prefix, "").replace(color_suffix, "") + + assert len(mock_connection.method_calls) == 1 + mock_connection.run = typing.cast(Mock, mock_connection.run) + mock_connection.run.assert_called_once() + + transformed_command = mock_connection.run.mock_calls[0][1][0] + # "#Connection({mock_connection.host!r}), + regression_file_text = f"""\ +After creating a Remote like so: + +```python +remote = {function_call_string(Remote, host, connection=mock_connection, transforms=())} +``` + +and then calling: + +```python +transformed_remote = remote.{function_call_string(method, *args)} +result = transformed_remote.run({command_to_run!r}) +``` + +Printed the following on the terminal: + +```console +{stdout} +``` + +The command that eventually would be run on the cluster is: + +```bash +{transformed_command} +``` + +and `result.stdout.strip()={repr(result.stdout.strip())}`. +""" + file_regression.check(regression_file_text, extension=".md") + + +@pytest.fixture(scope="function") +def remote(mock_connection: Connection): + return Remote(hostname=mock_connection.host, connection=mock_connection) + + +@pytest.mark.parametrize("message", ["foobar"]) +def test_display( + message: str, + remote: Remote, + capsys: pytest.CaptureFixture, +): + remote.display(message) + output = capsys.readouterr().out + # NOTE: This way of testing is also resilient to Pytest's `-s` option being used, + # since in that case some color codes are added to the output. + assert output in ( + T.bold_cyan(f"({remote.hostname}) $ {message}") + "\n", + f"({remote.hostname}) $ {message}\n", + ) + + +@pytest.fixture(params=[True, False]) +def hide( + request: pytest.FixtureRequest, capsys: pytest.CaptureFixture +) -> Generator[bool, None, None]: + """If `hide=True` is passed to `run` nothing should be printed to stdout/stderr.""" + value: bool = request.param + + yield value + if value: + output = capsys.readouterr() + assert output.out == "" + assert output.err == "" + + +@requires_s_flag +@pytest.mark.parametrize(("command", "expected_output"), [("echo OK", "OK")]) +@pytest.mark.parametrize("asynchronous", [True, False]) +@pytest.mark.parametrize("warn", [True, False]) +@pytest.mark.parametrize("display", [True, False, None]) +def test_run( + remote: Remote, + command: str, + expected_output: str, + asynchronous: bool, + hide: bool, + warn: bool, + display: bool | None, + capsys: pytest.CaptureFixture, +): + output = remote.run( + command, display=display, hide=hide, warn=warn, asynchronous=asynchronous + ) + if asynchronous: + import invoke.runners + + assert isinstance(output, invoke.runners.Promise) + output = output.join() + + stdout, stderr = capsys.readouterr() + + # Check that the original command is displayed in STDOUT if `display` is True or + # if display is unset and hide is False. + if display is not None: + assert (command in stdout) == display + elif not hide: + assert command in stdout + else: + assert command not in stdout + assert not stderr # shouldn't write anything to stderr. + + remote.connection.run.assert_called_once_with( + command, + asynchronous=asynchronous, + hide=hide, + warn=warn, + ) + remote.connection.local.assert_not_called() + + assert output.stdout == expected_output + "\n" + # NOTE: Not using a file regression fixture for now because of the large number + # of files and the small number of tested commands. + + # file_regression.check( + # "\n".join( + # [ + # f"With `remote = {repr(remote)}`:" + # f"calling `remote.{fn_call_string}` produced the following output:", + # "", + # "```console", + # stdout, + # "```", + # "", + # ] + # ), + # extension=".md", + # ) + + +@pytest.mark.parametrize("warn", [True, False]) +def test_get_output( + mock_connection: Mock, + host: str, + hide: bool, + warn: bool, +): + command = "echo OK" + command_output = "OK" + mock_result = Mock(wraps=invoke.runners.Result(Mock(wraps=command_output))) + mock_connection.run.return_value = mock_result + + r = Remote(host, connection=mock_connection) + output = r.get_output(command, display=None, hide=hide, warn=warn) + assert output == command_output + + assert len(mock_connection.method_calls) == 1 + mock_connection.run.assert_called_once_with(command, hide=hide, warn=warn) + mock_result.stdout.strip.assert_called_once_with() + + +@pytest.mark.parametrize("hide", [True, False]) +@pytest.mark.parametrize("warn", [True, False]) +def test_get_lines( + mock_connection: Connection, + host: str, + hide: bool, + warn: bool, +): + """ + BUG: Seems like either a bug or the name is misleading! `get_lines` + splits the output based on spaces, not lines! + """ + expected_lines = ["Line 1 has this value", "Line 2 has this other value"] + command = " && ".join(f"echo '{line}'" for line in expected_lines) + command_output = "\n".join(expected_lines) + mock_connection.run.return_value = invoke.runners.Result(stdout=command_output) + r = Remote(host, connection=mock_connection) + lines = r.get_lines(command, hide=hide, warn=warn) + # NOTE: We'd expect this, but instead we get ['Line', '1', 'has', 'this', 'value', + # TODO: Uncomment this if we fix `get_lines` to split based on lines, or remove this + # comment if we remove/rename `get_lines` to get_output().split() or similar. + # assert lines == expected_lines + # This is what we currently get: + assert ( + lines + == r.get_output(command, hide=hide, warn=warn).split() + == " ".join(expected_lines).split() + ) + + +def write_lines_with_sleeps(lines: Iterable[str], sleep_time: float = 0.1): + for line in lines: + print(line) + time.sleep(sleep_time) + + +@pytest.mark.parametrize("wait", [True, False]) +@pytest.mark.parametrize("pty", [True, False]) +def test_extract( + remote: Remote, + wait: bool, + pty: bool, +): + name = "bob" + lines = [ + "line 1", + f"hello my name is {name}", + "line 3", + "done", + ] + patterns = { + "foo": r"hello my name is ([A-Za-z0-9_-]+)", + } + expected_output = { + "foo": name, + } + + _runner, out = remote.extract( + # """for i in {1..15} ; do echo "This is the ${i}th echo" ; sleep 5 ; done""", + "&& sleep 0.2 && ".join(f"echo '{line}'" for line in lines), + patterns=patterns, + wait=wait, + pty=pty, + hide=False, + ) + assert out == expected_output + + +def _xfail_if_not_on_localhost(host: str): + if host != "localhost": + pytest.xfail("This test only works on localhost.") + + +def test_get(remote: Remote, tmp_path: Path, host: str): + # TODO: Make this test smarter? or no need? (because we'd be testing fabric at that + # point?) + _xfail_if_not_on_localhost(remote.hostname) + src = tmp_path / "foo" + dest = tmp_path / "bar" + source_content = "hello hello" + src.write_text(source_content) + _result = remote.get(str(src), str(dest)) + remote.connection.get.assert_called_once_with(str(src), str(dest)) + assert dest.read_text() == source_content + + +def test_put(remote: Remote, tmp_path: Path): + _xfail_if_not_on_localhost(remote.hostname) + src = tmp_path / "foo" + dest = tmp_path / "bar" + source_content = "hello hello" + src.write_text(source_content) + + result = remote.put(str(src), str(dest)) + remote.connection.put.assert_called_once_with(str(src), str(dest)) + import fabric.transfer + + assert isinstance(result, fabric.transfer.Result) + assert dest.read_text() == source_content + + +@requires_s_flag +def test_puttext(remote: Remote, tmp_path: Path): + _xfail_if_not_on_localhost(remote.hostname) + dest_dir = tmp_path / "bar/baz" + dest = tmp_path / f"{dest_dir}/bob.txt" + some_text = "foo" + _result = remote.puttext(some_text, str(dest)) + remote.connection.run.assert_called_once_with( + f"mkdir -p {dest_dir}", + hide=True, + ) + # The first argument of `put` will be the name of a temporary file. + remote.connection.put.assert_called_once_with(unittest.mock.ANY, str(dest)) + assert dest.read_text() == some_text + + +@requires_s_flag +def test_home(remote: Remote): + home_dir = remote.home() + remote.connection.run.assert_called_once_with("echo $HOME", hide=True) + remote.connection.local.assert_not_called() + if remote.hostname == "mila": + assert home_dir.startswith("/home/mila/") + elif remote.hostname == "localhost": + assert home_dir == str(Path.home()) + + +@requires_s_flag +def test_persist(remote: Remote, capsys: pytest.CaptureFixture): + _persisted_remote = remote.persist() + assert ( + "Warning: --persist does not work with --node or --job" + in capsys.readouterr().out + ) + assert _persisted_remote is remote + + +def test_ensure_allocation(remote: Remote): + assert remote.ensure_allocation() == ({"node_name": remote.hostname}, None) + + +def some_transform(x: str) -> str: + return f"echo Hello && {x}" + + +def some_other_transform(x: str) -> str: + return f"echo 'this is printed after the command' && {x}" + + +class TestSlurmRemote: + @pytest.mark.parametrize("persist", [True, False]) + def test_init(self, mock_connection: Connection, persist: bool): + alloc = ["--time=00:01:00"] + transforms = [some_transform] + remote = SlurmRemote( + mock_connection, alloc=alloc, transforms=transforms, persist=persist + ) + assert remote.connection is mock_connection + assert remote._persist == persist + assert remote.transforms == [ + *transforms, + remote.srun_transform_persist if persist else remote.srun_transform, + ] + + def test_srun_transform(self, mock_connection: Connection): + alloc = ["--time=00:01:00"] + transforms = [some_transform] + persist: bool = False + remote = SlurmRemote( + mock_connection, alloc=alloc, transforms=transforms, persist=persist + ) + command = "bob" + assert remote.srun_transform(command) == f"srun {alloc[0]} bash -c {command}" + + @requires_s_flag + def test_srun_transform_persist( + self, + mock_connection: Connection, + host: str, + file_regression: FileRegressionFixture, + monkeypatch: pytest.MonkeyPatch, + ): + _xfail_if_not_on_localhost(host) + alloc = ["--time=00:01:00"] + remote = SlurmRemote(mock_connection, alloc=alloc, transforms=(), persist=False) + command = "bob" + + # NOTE: It is unfortunately necessary for us to mock this function which we know + # the `srun_transform_persist` method will call to get a temporary file name, so + # that the regression file content is reproducible. + mock_time_ns = Mock(return_value=1234567890) + monkeypatch.setattr("time.time_ns", mock_time_ns) + + files_before = list((Path.home() / ".milatools" / "batch").rglob("*")) + output_command = remote.srun_transform_persist(command) + files_after = list((Path.home() / ".milatools" / "batch").rglob("*")) + + new_files = set(files_after) - set(files_before) + + assert len(new_files) == 1 + slurm_remote_constructor_call_str = function_call_string( + SlurmRemote, mock_connection, alloc=alloc, transforms=(), persist=False + ) + method_call_string = function_call_string( + remote.srun_transform_persist, command + ) + file_regression.check( + "\n".join( + [ + "After creating a SlurmRemote like so:", + "", + "```python", + f"remote = {slurm_remote_constructor_call_str}", + "```", + "", + "Calling this:" "", + "```python", + f"remote.{method_call_string}", + "```", + "", + "created the following files:", + "\n".join( + "\n\n".join( + [ + f"- {new_file}:", + "", + "```", + new_file.read_text(), + "```", + ] + ) + for new_file in new_files + ), + "", + "and produced the following command as output:", + "", + "```bash", + output_command, + "```", + "", + ] + ), + extension=".md", + ) + # TODO: Need to create a fixture for `persist` that checks if any files were + # created in ~/.milatools/batch, and if so, removes them after the test is done. + # Remove any new files. + for file in new_files: + file.unlink() + # If there wasn't a `~/.milatools` folder before, we should remove it after. + if not files_before: + shutil.rmtree(Path.home() / ".milatools") + + @pytest.mark.parametrize("persist", [True, False, None]) + def test_with_transforms(self, mock_connection: Connection, persist: bool | None): + # NOTE: This just tests what the body of the `SlurmRemote.with_transforms` does. + # It isn't a very useful test, but it's better than not having one for now. + # The test for Remote.run above checks that `run` on the transformed remote + # does what we expect. + assert SlurmRemote.run is Remote.run + alloc = ["--time=00:01:00"] + transforms = [some_transform] + new_transforms = [some_other_transform] + original_persist: bool = False + remote = SlurmRemote( + mock_connection, + alloc=alloc, + transforms=transforms, + persist=original_persist, + ) + + transformed = remote.with_transforms(*new_transforms, persist=persist) + assert transformed.connection is remote.connection + assert transformed.alloc == remote.alloc + assert transformed.transforms == [ + some_transform, + some_other_transform, + ( + transformed.srun_transform_persist + if persist + else transformed.srun_transform + ), + ] + assert transformed._persist == (remote._persist if persist is None else persist) + + @pytest.mark.parametrize("persist", [True, False]) + def test_persist(self, mock_connection: Connection, persist: bool): + # NOTE: This just checks what the body of the `SlurmRemote.persist` + # does. It isn't a very useful test, but it's better than not having one, for + # now. + alloc = ["--time=00:01:00"] + transforms = [some_transform] + remote = SlurmRemote( + mock_connection, alloc=alloc, transforms=transforms, persist=persist + ) + persisted = remote.persist() + assert persisted.connection == remote.connection + assert persisted.alloc == remote.alloc + assert persisted.transforms == [ + some_transform, + persisted.srun_transform_persist, + ] + assert persisted._persist is True + + persisted.run + + def test_ensure_allocation_persist(self, mock_connection: Connection): + # TODO: This test is not smart. It basically replicates the content of the + # method. We need to rework the SlurmRemote class so it is easier to test. + alloc = ["--time=00:01:00"] + remote = SlurmRemote(mock_connection, alloc=alloc, transforms=(), persist=True) + node_job_info = {"node_name": "bob", "jobid": "1234"} + remote.extract = Mock( + spec=remote.extract, + spec_set=True, + return_value=( + Mock(spec=invoke.runners.Runner, spec_set=True), + node_job_info, + ), + ) + + results, _runner = remote.ensure_allocation() + + remote.extract.assert_called_once_with( + "echo @@@ $(hostname) @@@ && sleep 1000d", + patterns={ + "node_name": "@@@ ([^ ]+) @@@", + "jobid": "Submitted batch job ([0-9]+)", + }, + hide=True, + ) + assert results == node_job_info + + def test_ensure_allocation_without_persist(self, mock_connection: Connection): + # TODO: This test is not smart. It basically replicates the content of the + # method. We need to rework the SlurmRemote class so it is easier to test. + alloc = ["--time=00:01:00"] + remote = SlurmRemote(mock_connection, alloc=alloc, transforms=(), persist=False) + node = "bob-123" + + def write_stuff( + command: str, + asynchronous: bool, + hide: bool, + pty: bool, + out_stream: QueueIO, + ): + assert command == f"bash -c 'salloc {shjoin(alloc)}'" + out_stream.write(f"salloc: Nodes {node} are ready for job") + return unittest.mock.DEFAULT + + mock_connection.run.side_effect = write_stuff + results, _runner = remote.ensure_allocation() -from milatools.cli.remote import QueueIO, get_first_node_name + mock_connection.run.assert_called_once_with( + f"bash -c 'salloc {shjoin(alloc)}'", + hide=False, + asynchronous=True, + out_stream=unittest.mock.ANY, + pty=True, + ) + assert results == {"node_name": node} -def test_QueueIO(file_regression): +def test_QueueIO(file_regression: FileRegressionFixture): # TODO: This test doesn't do much. qio = QueueIO() strs = [] diff --git a/tests/cli/test_remote/test_remote_transform_methods_localhost_with_bash_args4_initial_transforms0_echo_OK_.md b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_bash_args4_initial_transforms0_echo_OK_.md new file mode 100644 index 00000000..4c739f1e --- /dev/null +++ b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_bash_args4_initial_transforms0_echo_OK_.md @@ -0,0 +1,28 @@ +After creating a Remote like so: + +```python +remote = Remote('localhost', connection=Connection('localhost'), transforms=()) +``` + +and then calling: + +```python +transformed_remote = remote.with_bash() +result = transformed_remote.run('echo OK') +``` + +Printed the following on the terminal: + +```console +(localhost) $ echo OK +OK + +``` + +The command that eventually would be run on the cluster is: + +```bash +bash -c 'echo OK' +``` + +and `result.stdout.strip()='OK'`. diff --git a/tests/cli/test_remote/test_remote_transform_methods_localhost_with_precommand_args2_initial_transforms0_echo_OK_.md b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_precommand_args2_initial_transforms0_echo_OK_.md new file mode 100644 index 00000000..dfa0609f --- /dev/null +++ b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_precommand_args2_initial_transforms0_echo_OK_.md @@ -0,0 +1,29 @@ +After creating a Remote like so: + +```python +remote = Remote('localhost', connection=Connection('localhost'), transforms=()) +``` + +and then calling: + +```python +transformed_remote = remote.with_precommand("echo 'echo precommand'") +result = transformed_remote.run('echo OK') +``` + +Printed the following on the terminal: + +```console +(localhost) $ echo OK +echo precommand +OK + +``` + +The command that eventually would be run on the cluster is: + +```bash +echo 'echo precommand' && echo OK +``` + +and `result.stdout.strip()='echo precommand\nOK'`. diff --git a/tests/cli/test_remote/test_remote_transform_methods_localhost_with_profile_args3_initial_transforms0_echo_OK_.md b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_profile_args3_initial_transforms0_echo_OK_.md new file mode 100644 index 00000000..f67b9f0e --- /dev/null +++ b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_profile_args3_initial_transforms0_echo_OK_.md @@ -0,0 +1,28 @@ +After creating a Remote like so: + +```python +remote = Remote('localhost', connection=Connection('localhost'), transforms=()) +``` + +and then calling: + +```python +transformed_remote = remote.with_profile('.bashrc') +result = transformed_remote.run('echo OK') +``` + +Printed the following on the terminal: + +```console +(localhost) $ echo OK +OK + +``` + +The command that eventually would be run on the cluster is: + +```bash +source .bashrc && echo OK +``` + +and `result.stdout.strip()='OK'`. diff --git a/tests/cli/test_remote/test_remote_transform_methods_localhost_with_transforms_args0_initial_transforms0_echo_OK_.md b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_transforms_args0_initial_transforms0_echo_OK_.md new file mode 100644 index 00000000..c7227892 --- /dev/null +++ b/tests/cli/test_remote/test_remote_transform_methods_localhost_with_transforms_args0_initial_transforms0_echo_OK_.md @@ -0,0 +1,32 @@ +After creating a Remote like so: + +```python +remote = Remote('localhost', connection=Connection('localhost'), transforms=()) +``` + +and then calling: + +```python +transformed_remote = remote.with_transforms( + lambda cmd: cmd.replace("OK", "NOT_OK"), + lambda cmd: f"echo 'command before' && {cmd}", +) +result = transformed_remote.run('echo OK') +``` + +Printed the following on the terminal: + +```console +(localhost) $ echo OK +command before +NOT_OK + +``` + +The command that eventually would be run on the cluster is: + +```bash +echo 'command before' && echo NOT_OK +``` + +and `result.stdout.strip()='command before\nNOT_OK'`. diff --git a/tests/cli/test_remote/test_remote_transform_methods_localhost_wrap_args1_initial_transforms0_echo_OK_.md b/tests/cli/test_remote/test_remote_transform_methods_localhost_wrap_args1_initial_transforms0_echo_OK_.md new file mode 100644 index 00000000..f3bc1b5a --- /dev/null +++ b/tests/cli/test_remote/test_remote_transform_methods_localhost_wrap_args1_initial_transforms0_echo_OK_.md @@ -0,0 +1,29 @@ +After creating a Remote like so: + +```python +remote = Remote('localhost', connection=Connection('localhost'), transforms=()) +``` + +and then calling: + +```python +transformed_remote = remote.wrap("echo 'echo wrap' && {}") +result = transformed_remote.run('echo OK') +``` + +Printed the following on the terminal: + +```console +(localhost) $ echo OK +echo wrap +OK + +``` + +The command that eventually would be run on the cluster is: + +```bash +echo 'echo wrap' && echo OK +``` + +and `result.stdout.strip()='echo wrap\nOK'`. diff --git a/tests/cli/test_remote/test_srun_transform_persist_localhost_.md b/tests/cli/test_remote/test_srun_transform_persist_localhost_.md new file mode 100644 index 00000000..55d237de --- /dev/null +++ b/tests/cli/test_remote/test_srun_transform_persist_localhost_.md @@ -0,0 +1,39 @@ +After creating a SlurmRemote like so: + +```python +remote = SlurmRemote( + Connection('localhost'), + alloc=['--time=00:01:00'], + transforms=(), + persist=False, +) +``` + +Calling this: +```python +remote.srun_transform_persist('bob') +``` + +created the following files: +- /home/fabrice/.milatools/batch/batch-1234567890.sh: + + + +``` + +#!/bin/bash +#SBATCH --output=.milatools/batch/out-1234567890.txt +#SBATCH --ntasks=1 + +echo jobid = $SLURM_JOB_ID >> /dev/null + +bob + + +``` + +and produced the following command as output: + +```bash +sbatch --time=00:01:00 .milatools/batch/batch-1234567890.sh; touch .milatools/batch/out-1234567890.txt; tail -n +1 -f .milatools/batch/out-1234567890.txt +```