diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 698fb706..00000000 --- a/.flake8 +++ /dev/null @@ -1,10 +0,0 @@ -[flake8] -extend-ignore = - E203, - E266, - E501, - H301, - H306 -# line length is intentionally set to 80 here because black uses Bugbear -# See https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length for more details -max-line-length = 80 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce6e0bc2..43480215 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 # needed by setuptools-scm - - name: Switch to using Python 3.9 by default + - name: Switch to using Python 3.11 by default uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index c9dc37ad..0557d952 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -38,18 +38,21 @@ jobs: - name: Run tox -e lint run: | tox -e lint + - name: Run tox -e mypy + run: | + tox -e mypy build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - toxenv: [docs, packaging, py39] + toxenv: [docs, packaging, py311] steps: - uses: actions/checkout@v4 - - name: Set up Python 3.9 + - name: Set up Python 3.11 uses: actions/setup-python@v5 with: - python-version: "3.9" + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.gitignore b/.gitignore index cdfa6ebf..9bf9e7c8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ dist AUTHORS ChangeLog coverage.xml -flake8.report junit*.xml doc/build .cache diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b1e0a51..ee373d6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,38 +25,15 @@ repos: - id: check-merge-conflict - id: debug-statements language_version: python3 - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - repo: local hooks: - - id: isort - args: - # https://github.com/pre-commit/mirrors-isort/issues/9#issuecomment-624404082 - - --filter-files - - repo: https://github.com/psf/black - rev: 22.10.0 - hooks: - - id: black - language_version: python3 - - repo: https://github.com/pycqa/flake8.git - rev: 6.0.0 - hooks: - - id: flake8 - language_version: python3 - additional_dependencies: - - flake8-bugbear - - flake8-comprehensions - - flake8-debugger - - flake8-logging-format - - flake8-pep3101 - - flake8-print - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.4.1 - hooks: - - id: mypy - # empty args needed in order to match mypy cli behavior - additional_dependencies: - - types-paramiko - - types-setuptools - - setuptools-scm - - alabaster - - pytest + - id: ruff-check + name: ruff-check + entry: ruff check + language: system + types: [python] + - id: ruff-format + name: ruff + entry: ruff format --check --diff + language: system + types: [python] diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 84ece924..d3066a2d 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -2,7 +2,10 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.9" + python: "3.11" python: install: - - requirements: dev-requirements.txt + - method: pip + path: . + extra_requirements: + - doc diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e27e172f..0e4f5fe1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,28 @@ Changelog ========= +10.2.0 +====== + +* [NEW] Query all usernames and group names +* [FIX] Prevent Paramiko deadlock when test sends more than 2MB to stdout +* [FIX] Follow changes in ansible shell module +* [FIX] Add 4 to the expected exit code when running "systemctl is-active" +* [FIX] Fix KeyError in MountPoint.__repr__() if mount does not exist +* [DOC] Use pytest command instead of py.test +* [DOC] Extend backend documentation with a general host spec section +* [MISC] Also run lint for py 3.12 and 3.13 +* [MISC] Switch packaging to use hatchling +* [MISC] Drop unused extra "args" argument to run_winrm() +* [MISC] Use ruff format instead of black/isort +* [MISC] Use ruff instead of flake8 +* [MISC] Use f-string instead of str.format() +* [MISC] Use builtin dict, list and tuple for typing +* [MISC] Use python 3.11 during tests +* [MISC] Fix salt tests +* [MISC] Fix tests failing due to expiration date passed +* [MISC] Remove crypt lib from testing + 10.1.1 ====== diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b46ac69d..f0aac37d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -30,10 +30,7 @@ To run only some selected tests:: Code style ========== -Your code must pass without errors under `flake8 -`_ with the extension `hacking -`_:: +Your code must pass without errors under `ruff `_ - - pip install hacking - flake8 testinfra + pip install ruff + ruff check diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 26e9fc65..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,21 +0,0 @@ -recursive-include testinfra *.py -recursive-include test *.py ssh_key -recursive-include images Dockerfile -recursive-include doc *.py *.rst *.svg -include doc/source/_templates/piwik.html -include doc/Makefile -include tox.ini -include .flake8 -include mypy.ini -include Makefile -include *.yaml -include README.rst CONTRIBUTING.rst CHANGELOG.rst -include MANIFEST.in -include ansible.cfg -include dev-requirements.txt -include test-requirements.txt -include LICENSE -exclude .editorconfig -exclude .gitignore -prune doc/build -prune .github diff --git a/README.rst b/README.rst index db12d6c3..7e888e9d 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ Write your first tests file to `test_myinfra.py`: And run it:: - $ py.test -v test_myinfra.py + $ pytest -v test_myinfra.py ====================== test session starts ====================== diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 2509dd7a..00000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sphinx>=7.1,<7.2 -alabaster>=0.7.2 -. diff --git a/doc/source/backends.rst b/doc/source/backends.rst index 8a14c022..25a3530f 100644 --- a/doc/source/backends.rst +++ b/doc/source/backends.rst @@ -13,6 +13,63 @@ system packaged tools may still be required). For example :: For all backends, commands can be run as superuser with the ``--sudo`` option or as specific user with the ``--sudo-user`` option. +General Host specification +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``--hosts`` parameter in Testinfra is used to specify the target hosts for the tests. + +You can specify multiple hosts by separating each target with a comma, allowing you to run tests using different backends across different environments or machines. + +The user, password, and port fields are optional, providing flexibility depending on your authentication and connection requirements. + +Please also read the details for the individual backends, as the host spec is handled slightly differently from backend to backend. + +**Syntax:** + +:: + + --hosts=://:@: + + +**Components:** + +* ````: type of backend to be used (e.g., ssh, docker, paramiko, local) +* ````: username for authentication (optional) +* ````: password for authentication (optional) +* ````: target hostname or IP address +* ````: target port number (optional) + +Special characters (e.g. ":") in the user and password fields need to be percent-encoded according to RFC 3986. This can be done using ``urllib.parse.quote()`` in Python. + +For example:: + + import urllib.parse + + user = urllib.parse.quote('user:name') + password = urllib.parse.quote('p@ssw:rd') + host = 'hostname' + port = 22 + + host_spec = f"ssh://{user}:{password}@{host}:{port}" + print(host_spec) + +This will ensure that any special characters are properly encoded, making the connection string valid. + +**Examples:** + +SSH Backend with Full Specification:: + + testinfra --hosts=ssh://user:password@hostname:22 + +Docker Backend:: + + testinfra --hosts=docker://container_id + +Mixed Backends:: + + testinfra --hosts=ssh://user:password@hostname:22,docker://container_id,local:// + + local ~~~~~ @@ -20,7 +77,7 @@ This is the default backend when no hosts are provided (either via ``--hosts`` or in modules). Commands are run locally in a subprocess under the current user:: - $ py.test --sudo test_myinfra.py + $ pytest --sudo test_myinfra.py paramiko @@ -34,7 +91,7 @@ able to connect without password (using passwordless keys or using You can provide an alternate ssh-config:: - $ py.test --ssh-config=/path/to/ssh_config --hosts=server + $ pytest --ssh-config=/path/to/ssh_config --hosts=server docker @@ -43,7 +100,7 @@ docker The Docker backend can be used to test *running* Docker containers. It uses the `docker exec `_ command:: - $ py.test --hosts='docker://[user@]container_id_or_name' + $ pytest --hosts='docker://[user@]container_id_or_name' See also the :ref:`Test docker images` example. @@ -54,7 +111,7 @@ podman The Podman backend can be used to test *running* Podman containers. It uses the `podman exec `_ command:: - $ py.test --hosts='podman://[user@]container_id_or_name' + $ pytest --hosts='podman://[user@]container_id_or_name' ssh @@ -62,11 +119,11 @@ ssh This is a pure SSH backend using the ``ssh`` command. Example:: - $ py.test --hosts='ssh://server' - $ py.test --ssh-config=/path/to/ssh_config --hosts='ssh://server' - $ py.test --ssh-identity-file=/path/to/key --hosts='ssh://server' - $ py.test --hosts='ssh://server?timeout=60&controlpersist=120' - $ py.test --hosts='ssh://server' --ssh-extra-args='-o StrictHostKeyChecking=no' + $ pytest --hosts='ssh://server' + $ pytest --ssh-config=/path/to/ssh_config --hosts='ssh://server' + $ pytest --ssh-identity-file=/path/to/key --hosts='ssh://server' + $ pytest --hosts='ssh://server?timeout=60&controlpersist=120' + $ pytest --hosts='ssh://server' --ssh-extra-args='-o StrictHostKeyChecking=no' By default timeout is set to 10 seconds and ControlPersist is set to 60 seconds. You can disable persistent connection by passing `controlpersist=0` to the options. @@ -78,10 +135,10 @@ salt The salt backend uses the `salt Python client API `_ and can be used from the salt-master server:: - $ py.test --hosts='salt://*' - $ py.test --hosts='salt://minion1,salt://minion2' - $ py.test --hosts='salt://web*' - $ py.test --hosts='salt://G@os:Debian' + $ pytest --hosts='salt://*' + $ pytest --hosts='salt://minion1,salt://minion2' + $ pytest --hosts='salt://web*' + $ pytest --hosts='salt://G@os:Debian' Testinfra will use the salt connection channel to run commands. @@ -99,9 +156,9 @@ and how to connect them, using Testinfra's Ansible backend. To use the Ansible backend, prefix the ``--hosts`` option with ``ansible://`` e.g:: - $ py.test --hosts='ansible://all' # tests all inventory hosts - $ py.test --hosts='ansible://host1,ansible://host2' - $ py.test --hosts='ansible://web*' + $ pytest --hosts='ansible://all' # tests all inventory hosts + $ pytest --hosts='ansible://host1,ansible://host2' + $ pytest --hosts='ansible://web*' An inventory may be specified with the ``--ansible-inventory`` option, otherwise the default (``/etc/ansible/hosts``) is used. @@ -112,8 +169,8 @@ are supported values. Other connections (or if you are using the ``--force-ansib option) will result in testinfra running all commands via Ansible itself, which is substantially slower than the other backends:: - $ py.test --force-ansible --hosts='ansible://all' - $ py.test --hosts='ansible://host?force_ansible=True' + $ pytest --force-ansible --hosts='ansible://all' + $ pytest --hosts='ansible://host?force_ansible=True' By default, the Ansible connection backend will first try to use ``ansible_ssh_private_key_file`` and ``ansible_private_key_file`` to authenticate, @@ -146,14 +203,14 @@ support connecting to a given container name within a pod and using a given namespace:: # will use the default namespace and default container - $ py.test --hosts='kubectl://mypod-a1b2c3' + $ pytest --hosts='kubectl://mypod-a1b2c3' # specify container name and namespace - $ py.test --hosts='kubectl://somepod-2536ab?container=nginx&namespace=web' + $ pytest --hosts='kubectl://somepod-2536ab?container=nginx&namespace=web' # specify the kubeconfig context to use - $ py.test --hosts='kubectl://somepod-2536ab?context=k8s-cluster-a&container=nginx' + $ pytest --hosts='kubectl://somepod-2536ab?context=k8s-cluster-a&container=nginx' # you can specify kubeconfig either from KUBECONFIG environment variable # or when working with multiple configuration with the "kubeconfig" option - $ py.test --hosts='kubectl://somepod-123?kubeconfig=/path/kubeconfig,kubectl://otherpod-123?kubeconfig=/other/kubeconfig' + $ pytest --hosts='kubectl://somepod-123?kubeconfig=/path/kubeconfig,kubectl://otherpod-123?kubeconfig=/other/kubeconfig' openshift ~~~~~~~~~ @@ -164,25 +221,25 @@ support connecting to a given container name within a pod and using a given namespace:: # will use the default namespace and default container - $ py.test --hosts='openshift://mypod-a1b2c3' + $ pytest --hosts='openshift://mypod-a1b2c3' # specify container name and namespace - $ py.test --hosts='openshift://somepod-2536ab?container=nginx&namespace=web' + $ pytest --hosts='openshift://somepod-2536ab?container=nginx&namespace=web' # you can specify kubeconfig either from KUBECONFIG environment variable # or when working with multiple configuration with the "kubeconfig" option - $ py.test --hosts='openshift://somepod-123?kubeconfig=/path/kubeconfig,openshift://otherpod-123?kubeconfig=/other/kubeconfig' + $ pytest --hosts='openshift://somepod-123?kubeconfig=/path/kubeconfig,openshift://otherpod-123?kubeconfig=/other/kubeconfig' winrm ~~~~~ The winrm backend uses `pywinrm `_:: - $ py.test --hosts='winrm://Administrator:Password@127.0.0.1' - $ py.test --hosts='winrm://vagrant@127.0.0.1:2200?no_ssl=true&no_verify_ssl=true' + $ pytest --hosts='winrm://Administrator:Password@127.0.0.1' + $ pytest --hosts='winrm://vagrant@127.0.0.1:2200?no_ssl=true&no_verify_ssl=true' pywinrm's default read and operation timeout can be overridden using query arguments ``read_timeout_sec`` and ``operation_timeout_sec``:: - $ py.test --hosts='winrm://vagrant@127.0.0.1:2200?read_timeout_sec=120&operation_timeout_sec=100' + $ pytest --hosts='winrm://vagrant@127.0.0.1:2200?read_timeout_sec=120&operation_timeout_sec=100' LXC/LXD ~~~~~~~ @@ -190,4 +247,4 @@ LXC/LXD The LXC backend can be used to test *running* LXC or LXD containers. It uses the `lxc exec `_ command:: - $ py.test --hosts='lxc://container_name' + $ pytest --hosts='lxc://container_name' diff --git a/doc/source/conf.py b/doc/source/conf.py index 73c41693..43cfc79e 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -22,8 +22,8 @@ # serve to show the default. import datetime +import importlib.metadata import os -import subprocess import sys import alabaster @@ -61,21 +61,14 @@ # General information about the project. project = "testinfra" -copyright = "{}, Philippe Pepiot".format(datetime.date.today().year) +copyright = f"{datetime.date.today().year}, Philippe Pepiot" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = ( - subprocess.check_output( - ["python3", "setup.py", "--version"], - cwd=os.path.join(os.path.dirname(__file__), os.pardir, os.pardir), - ) - .decode() - .strip() -) +version = importlib.metadata.version("pytest-testinfra") # The full version, including alpha/beta/rc tags. release = version diff --git a/doc/source/examples.rst b/doc/source/examples.rst index 91a56299..e5d2fc12 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -18,7 +18,7 @@ Pytest support `test parametrization .vagrant/ssh-config - py.test --hosts=default --ssh-config=.vagrant/ssh-config tests.py + pytest --hosts=default --ssh-config=.vagrant/ssh-config tests.py Integration with Jenkins @@ -101,7 +101,7 @@ If your Jenkins slave can run Vagrant, your build scripts can be like:: pip install pytest-testinfra paramiko vagrant up vagrant ssh-config > .vagrant/ssh-config - py.test --hosts=default --ssh-config=.vagrant/ssh-config --junit-xml junit.xml tests.py + pytest --hosts=default --ssh-config=.vagrant/ssh-config --junit-xml junit.xml tests.py Then configure Jenkins to get tests results from the `junit.xml` file. @@ -117,12 +117,12 @@ This kind of tests are close to monitoring checks, so let's push them to The Testinfra option `--nagios` enables a behavior compatible with a nagios plugin:: - $ py.test -qq --nagios --tb line test_ok.py; echo $? + $ pytest -qq --nagios --tb line test_ok.py; echo $? TESTINFRA OK - 2 passed, 0 failed, 0 skipped in 2.30 seconds .. 0 - $ py.test -qq --nagios --tb line test_fail.py; echo $? + $ pytest -qq --nagios --tb line test_fail.py; echo $? TESTINFRA CRITICAL - 1 passed, 1 failed, 0 skipped in 2.24 seconds .F /usr/lib/python3/dist-packages/example/example.py:95: error: [Errno 111] error msg @@ -142,7 +142,7 @@ additionally (on your host machine, not in the VM handled by kitchen) :: verifier: name: shell - command: py.test --hosts="paramiko://${KITCHEN_USERNAME}@${KITCHEN_HOSTNAME}:${KITCHEN_PORT}?ssh_identity_file=${KITCHEN_SSH_KEY}" --junit-xml "junit-${KITCHEN_INSTANCE}.xml" "test/integration/${KITCHEN_SUITE}" + command: pytest --hosts="paramiko://${KITCHEN_USERNAME}@${KITCHEN_HOSTNAME}:${KITCHEN_PORT}?ssh_identity_file=${KITCHEN_SSH_KEY}" --junit-xml "junit-${KITCHEN_INSTANCE}.xml" "test/integration/${KITCHEN_SUITE}" .. _test docker images: diff --git a/doc/source/invocation.rst b/doc/source/invocation.rst index 1d744e54..b6e3378b 100644 --- a/doc/source/invocation.rst +++ b/doc/source/invocation.rst @@ -10,7 +10,7 @@ test remotes systems using `paramiko `_ (a ssh implementation in python):: $ pip install paramiko - $ py.test -v --hosts=localhost,root@webserver:2222 test_myinfra.py + $ pytest -v --hosts=localhost,root@webserver:2222 test_myinfra.py ====================== test session starts ====================== platform linux -- Python 2.7.3 -- py-1.4.26 -- pytest-2.6.4 @@ -45,7 +45,7 @@ If you have a lot of tests, you can use the pytest-xdist_ plugin to run tests us $ pip install pytest-xdist # Launch tests using 3 processes - $ py.test -n 3 -v --host=web1,web2,web3,web4,web5,web6 test_myinfra.py + $ pytest -n 3 -v --host=web1,web2,web3,web4,web5,web6 test_myinfra.py Advanced invocation @@ -54,10 +54,10 @@ Advanced invocation :: # Test recursively all test files (starting with `test_`) in current directory - $ py.test + $ pytest # Filter function/hosts with pytest -k option - $ py.test --hosts=webserver,dnsserver -k webserver -k nginx + $ pytest --hosts=webserver,dnsserver -k webserver -k nginx For more usages and features, see the Pytest_ documentation. diff --git a/hatch.toml b/hatch.toml new file mode 100644 index 00000000..1b472f10 --- /dev/null +++ b/hatch.toml @@ -0,0 +1,8 @@ +[version] +source = "vcs" + +[build.targets.sdist] +packages = ["testinfra"] + +[build.targets.wheel] +packages = ["testinfra"] diff --git a/images/debian_bookworm/Dockerfile b/images/debian_bookworm/Dockerfile index 8fc65c15..d6b4d5f8 100644 --- a/images/debian_bookworm/Dockerfile +++ b/images/debian_bookworm/Dockerfile @@ -58,7 +58,7 @@ RUN echo "user:foo" | chpasswd RUN echo "*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n-A PREROUTING -d 192.168.0.1/32 -j REDIRECT\nCOMMIT\n*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT\nCOMMIT" > /etc/iptables/rules.v4 # Expiration date for user "user" -RUN chage -E 20000 -m 7 -M 90 user +RUN chage -E 2030-01-01 -m 7 -M 90 user # Some python3 virtualenv RUN virtualenv /v @@ -66,12 +66,7 @@ RUN /v/bin/pip install -U pip RUN /v/bin/pip install 'requests==2.30.0' # install salt -ARG _BUILD_DEPS="gcc g++ libc6-dev python3-dev" -RUN apt update && apt install -y $_BUILD_DEPS && \ - python3 -m pip install --break-system-packages --no-cache salt && \ - apt -y purge $_BUILD_DEPS && \ - apt -y autoremove --purge && \ - rm -rf /var/lib/apt/lists/* +RUN python3 -m pip install --break-system-packages --no-cache salt tornado distro looseversion msgpack pyyaml packaging jinja2 ENV LANG fr_FR.ISO-8859-15 ENV LANGUAGE fr_FR diff --git a/pyproject.toml b/pyproject.toml index be938671..52ae53e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,60 @@ -[tool.black] -target-version = ['py38'] -include = '\.pyi?$' -exclude = ''' -( - /( - \.git - | \.tox - | \.eggs - | build - | dist - )/ -) -''' +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" -[tool.isort] -profile = "black" -multi_line_output = 3 +[project] +name = "pytest-testinfra" +description = "Test infrastructures" +requires-python = ">=3.9" +dynamic = ["version"] +readme = "README.rst" +license-files = ["LICENSE"] +authors = [{ name = "Philippe Pepiot", email = "phil@philpep.org" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Testing", + "Topic :: System :: Systems Administration", + "Framework :: Pytest", +] -known_first_party = ["testinfra"] +dependencies = [ + "pytest>=6", +] + +[project.optional-dependencies] +ansible = ["ansible"] +paramiko = ["paramiko"] +salt = ["salt", "tornado", "distro", "looseversion", "msgpack"] +winrm = ["pywinrm"] +test = [ + "pytest-cov", + "pytest-xdist", + "pytest-testinfra[ansible,paramiko,salt,winrm]", +] +typing = [ + "mypy", + "types-paramiko", +] +lint = [ + "ruff", +] +doc = [ + "sphinx>=7.1,<7.2", + "alabaster>=0.7.2", +] +dev = ["pytest-testinfra[test,typing,doc,lint]"] + +[project.entry-points."pytest11"] +"pytest11.testinfra" = "testinfra.plugin" diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..d50530a3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,71 @@ +target-version = "py39" + +[lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-debugger + "T10", + # flake8-logging + "G", + # flake8-comprehension + "C4", + # flake8-simplify + "SIM", + # flake8-print + "T20", + # individual rules + "RUF100", # unused-noqa + # imports + "I", +] + +# For error codes, see https://docs.astral.sh/ruff/rules/#error-e +ignore = [ +# argument is shadowing a Python builtin + "A001", + "A002", + "A005", +# attribute overriding builtin name + "A003", +# [bugbear] Do not use mutable data structures for argument defaults (I choose by myself) + "B006", +# [bugbear] Do not perform function calls in argument defaults (conflict with fastapi dependency system) + "B008", +# [bugbear] Function definition does not bind loop variable + "B023", +# closing bracket does not match indentation of opening bracket's line (disagree with emacs python mode) +# "E123", +# continuation line over-indented for hanging indent line (disagree with emacs python mode) + # "E126", +# whitespace before ':' + "E203", +# missing whitespace around arithmetic operator (disagree when in parameter) + "E226", +# line too long + "E501", +# multiple statements on one line (def) (not compatible with black) +# "E704", +# do not assign lambda expression (this is inconvenient) + "E731", +# Logging statement uses exception in arguments (seems legit to display only str representation of exc) +# "G200", +# [string-format] format string does contain unindexed parameters (don't care of py2.6) +# "P101", +# [string-format] docstring does contain unindexed parameters +# "P102", +# [string-format] other string does contain unindexed parameters +# "P103", +# Line break occurred before a binary operator (to keep operators aligned) +# "W503", +] + +[lint.isort] +known-first-party = ["testinfra"] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 37e681ef..00000000 --- a/setup.cfg +++ /dev/null @@ -1,53 +0,0 @@ -[metadata] -name = pytest-testinfra -url = https://github.com/pytest-dev/pytest-testinfra -description = Test infrastructures -long_description = file:README.rst -long_description_content_type = text/x-rst -author = Philippe Pepiot -author_email = phil@philpep.org -license_files = LICENSE -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Console - Intended Audience :: Developers - Intended Audience :: Information Technology - Intended Audience :: System Administrators - License :: OSI Approved :: Apache Software License - Operating System :: POSIX - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Topic :: Software Development :: Testing - Topic :: System :: Systems Administration - Framework :: Pytest - -[options] -use_scm_version = True -python_requires = >=3.9 -packages = find: -setup_requires = - setuptools_scm -install_requires = - pytest>=6 -extras_require = - -[options.extras_require] -ansible = ansible -docker = -kubectl = -local = -lxc = -paramiko = paramiko -salt = salt -winrm = pywinrm - -[options.entry_points] -pytest11 = - pytest11.testinfra=testinfra.plugin - -[tool:pytest] -norecursedirs = .tox .git .local *.egg build diff --git a/setup.py b/setup.py deleted file mode 100644 index ea3e86c4..00000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import setuptools - - -def local_scheme(version): - """Generate a PEP440 compatible version if PEP440_VERSION is enabled""" - import os - - import setuptools_scm.version # only present during setup time - - return ( - "" - if "PEP440_VERSION" in os.environ - else setuptools_scm.version.get_local_node_and_date(version) - ) - - -if __name__ == "__main__": - setuptools.setup( - use_scm_version={"local_scheme": local_scheme}, - setup_requires=["setuptools_scm"], - ) diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index afc3067f..00000000 --- a/test-requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -pytest-cov -pytest-xdist -paramiko -types-paramiko -salt -pywinrm -ansible diff --git a/test/conftest.py b/test/conftest.py index 107c0af3..ba99a643 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -62,29 +62,27 @@ def has_docker(): def setup_ansible_config(tmpdir, name, host, user, port, key): items = [ name, - "ansible_ssh_private_key_file={}".format(key), - 'ansible_ssh_common_args="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=FATAL"', # noqa + f"ansible_ssh_private_key_file={key}", + 'ansible_ssh_common_args="-o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=FATAL"', "myvar=foo", - "ansible_host={}".format(host), - "ansible_user={}".format(user), - "ansible_port={}".format(port), + f"ansible_host={host}", + f"ansible_user={user}", + f"ansible_port={port}", ] tmpdir.join("inventory").write("[testgroup]\n" + " ".join(items) + "\n") tmpdir.mkdir("host_vars").join(name).write(ANSIBLE_HOSTVARS) tmpdir.mkdir("group_vars").join("testgroup").write( - ("---\n" "myhostvar: should_be_overriden\n" "mygroupvar: qux\n") + "---\nmyhostvar: should_be_overriden\nmygroupvar: qux\n" ) vault_password_file = tmpdir.join("vault-pass.txt") vault_password_file.write("polichinelle\n") ansible_cfg = tmpdir.join("ansible.cfg") ansible_cfg.write( - ( - "[defaults]\n" - "vault_password_file={}\n" - "host_key_checking=False\n\n" - "[ssh_connection]\n" - "pipelining=True\n" - ).format(str(vault_password_file)) + "[defaults]\n" + f"vault_password_file={str(vault_password_file)}\n" + "host_key_checking=False\n\n" + "[ssh_connection]\n" + "pipelining=True\n" ) @@ -119,7 +117,7 @@ def teardown(): return docker_id, docker_host, port - fname = "_docker_container_{}_{}".format(image, scope) + fname = f"_docker_container_{image}_{scope}" mod = sys.modules[__name__] setattr(mod, fname, func) @@ -147,7 +145,7 @@ def host(request, tmpdir_factory): else: scope = "session" - fname = "_docker_container_{}_{}".format(spec.name, scope) + fname = f"_docker_container_{spec.name}_{scope}" docker_id, docker_host, port = request.getfixturevalue(fname) if kw["connection"] == "docker": @@ -156,7 +154,8 @@ def host(request, tmpdir_factory): hostname = spec.name tmpdir = tmpdir_factory.mktemp(str(id(request))) key = tmpdir.join("ssh_key") - key.write(open(os.path.join(BASETESTDIR, "ssh_key")).read()) + with open(os.path.join(BASETESTDIR, "ssh_key")) as f: + key.write(f.read()) key.chmod(384) # octal 600 if kw["connection"] == "ansible": setup_ansible_config( @@ -168,26 +167,21 @@ def host(request, tmpdir_factory): else: ssh_config = tmpdir.join("ssh_config") ssh_config.write( - ( - "Host {}\n" - " Hostname {}\n" - " Port {}\n" - " UserKnownHostsFile /dev/null\n" - " StrictHostKeyChecking no\n" - " IdentityFile {}\n" - " IdentitiesOnly yes\n" - " LogLevel FATAL\n" - ).format(hostname, docker_host, port, str(key)) + f"Host {hostname}\n" + f" Hostname {docker_host}\n" + f" Port {port}\n" + " UserKnownHostsFile /dev/null\n" + " StrictHostKeyChecking no\n" + f" IdentityFile {str(key)}\n" + " IdentitiesOnly yes\n" + " LogLevel FATAL\n" ) kw["ssh_config"] = str(ssh_config) # Wait ssh to be up service = testinfra.get_host(docker_id, connection="docker").service - if image == "rockylinux9": - service_name = "sshd" - else: - service_name = "ssh" + service_name = "sshd" if image == "rockylinux9" else "ssh" while not service(service_name).is_running: time.sleep(0.5) @@ -232,7 +226,7 @@ def build_image(build_failed, dockerfile, image, image_path): "-f", dockerfile, "-t", - "testinfra:{0}".format(image), + f"testinfra:{image}", image_path, ] ) diff --git a/test/test_backends.py b/test/test_backends.py index b805eeb4..ea7bc311 100644 --- a/test/test_backends.py +++ b/test/test_backends.py @@ -86,8 +86,9 @@ def test_encoding(host): elif host.backend.get_connection_type() == "ansible" and host.backend.force_ansible: # XXX: this encoding issue comes directly from ansible # not sure how to handle this... - assert cmd.stderr == ( - "ls: impossible d'accéder à '/é': " "Aucun fichier ou dossier de ce type" + assert ( + cmd.stderr + == "ls: impossible d'acc\udce9der \udce0 '/é': Aucun fichier ou dossier de ce type" ) else: assert cmd.stderr_bytes == ( @@ -95,7 +96,7 @@ def test_encoding(host): b"Aucun fichier ou dossier de ce type\n" ) assert cmd.stderr == ( - "ls: impossible d'accéder à '/é': " "Aucun fichier ou dossier de ce type\n" + "ls: impossible d'accéder à '/é': Aucun fichier ou dossier de ce type\n" ) @@ -124,18 +125,16 @@ def test_sudo(host): def test_ansible_get_hosts(): with tempfile.NamedTemporaryFile() as f: f.write( - ( - b"ungrp\n" - b"[g1]\n" - b"debian\n" - b"[g2]\n" - b"rockylinux\n" - b"[g3:children]\n" - b"g1\n" - b"g2\n" - b"[g4:children]\n" - b"g3" - ) + b"ungrp\n" + b"[g1]\n" + b"debian\n" + b"[g2]\n" + b"rockylinux\n" + b"[g3:children]\n" + b"g1\n" + b"g2\n" + b"[g4:children]\n" + b"g3" ) f.flush() @@ -157,16 +156,14 @@ def get_hosts(spec): def test_ansible_get_variables(): with tempfile.NamedTemporaryFile() as f: f.write( - ( - b"debian a=b c=d\n" - b"rockylinux e=f\n" - b"[all:vars]\n" - b"a=a\n" - b"[g]\n" - b"debian\n" - b"[g:vars]\n" - b"x=z\n" - ) + b"debian a=b c=d\n" + b"rockylinux e=f\n" + b"[all:vars]\n" + b"a=a\n" + b"[g]\n" + b"debian\n" + b"[g:vars]\n" + b"x=z\n" ) f.flush() @@ -201,7 +198,7 @@ def get_vars(host): ( {}, b"host ansible_connection=local ansible_become=yes ansible_become_user=u", - { # noqa + { "NAME": "local", "sudo": True, "sudo_user": "u", @@ -226,7 +223,7 @@ def get_vars(host): ( {}, b"host ansible_host=127.0.1.1 ansible_user=u ansible_ssh_private_key_file=key ansible_port=2222 ansible_become=yes ansible_become_user=u", - { # noqa + { "NAME": "ssh", "sudo": True, "sudo_user": "u", @@ -238,7 +235,7 @@ def get_vars(host): ( {}, b"host ansible_host=127.0.1.1 ansible_user=u ansible_private_key_file=key ansible_port=2222 ansible_become=yes ansible_become_user=u", - { # noqa + { "NAME": "ssh", "sudo": True, "sudo_user": "u", @@ -268,7 +265,7 @@ def get_vars(host): ( {}, b'host ansible_ssh_common_args="-o StrictHostKeyChecking=no" ansible_ssh_extra_args="-o LogLevel=FATAL"', - { # noqa + { "NAME": "ssh", "host.name": "host", "ssh_extra_args": "-o StrictHostKeyChecking=no -o LogLevel=FATAL", @@ -295,7 +292,7 @@ def get_vars(host): ( {}, b"host ansible_connection=docker ansible_become=yes ansible_become_user=u ansible_user=z ansible_host=container", - { # noqa + { "NAME": "docker", "name": "container", "user": "z", @@ -346,7 +343,7 @@ def test_ansible_get_host(kwargs, inventory, expected): # identity_file has highest priority ( b"host ansible_user=user ansible_ssh_pass=password ansible_ssh_private_key_file=some_file", - ( # noqa + ( "ssh -o User=user -i some_file " "-o ConnectTimeout=10 -o ControlMaster=auto " "-o ControlPersist=60s host true" @@ -372,7 +369,7 @@ def test_ansible_get_host(kwargs, inventory, expected): # escape % ( b'host ansible_ssh_extra_args="-o ControlPath ~/.ssh/ansible/cp/%r@%h-%p"', - ( # noqa + ( "ssh -o ControlPath ~/.ssh/ansible/cp/%r@%h-%p -o ConnectTimeout=10 " "-o ControlMaster=auto -o ControlPersist=60s host true" ), @@ -418,7 +415,7 @@ def test_ansible_config(): # test testinfra use ANSIBLE_CONFIG tmp = tempfile.NamedTemporaryFile with tmp(suffix=".cfg") as cfg, tmp() as inventory: - cfg.write((b"[defaults]\n" b"inventory=" + inventory.name.encode() + b"\n")) + cfg.write(b"[defaults]\ninventory=" + inventory.name.encode() + b"\n") cfg.flush() inventory.write(b"h\n") inventory.flush() @@ -640,3 +637,12 @@ def test_get_hosts(): ("a", 10), ("a", 1), ] + + +@pytest.mark.testinfra_hosts(*HOSTS) +def test_command_deadlock(host): + # Test for deadlock when exceeding Paramiko transport buffer (2MB) + # https://docs.paramiko.org/en/latest/api/channel.html#paramiko.channel.Channel.recv_exit_status + size = 3 * 1024 * 1024 + output = host.check_output(f"python3 -c 'print(\"a\" * {size})'") + assert len(output) == size diff --git a/test/test_modules.py b/test/test_modules.py index 23a1f2f2..e781c759 100644 --- a/test/test_modules.py +++ b/test/test_modules.py @@ -10,20 +10,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -import crypt import datetime import os import re +import tempfile +import textwrap import time from ipaddress import IPv4Address, IPv6Address, ip_address import pytest from testinfra.modules.socket import parse_socketspec +from testinfra.utils.ansible_runner import AnsibleRunner all_images = pytest.mark.testinfra_hosts( *[ - "docker://{}".format(image) + f"docker://{image}" for image in ( "rockylinux9", "debian_bookworm", @@ -48,7 +50,7 @@ def test_package(host, docker_image): }[docker_image] if release is None: with pytest.raises(NotImplementedError): - ssh.release + ssh.release # noqa: B018 else: assert release in ssh.release @@ -64,7 +66,7 @@ def test_held_package(host): def test_rpmdb_corrupted(host): host.check_output("dd if=/dev/zero of=/var/lib/rpm/rpmdb.sqlite bs=1024 count=1") with pytest.raises(RuntimeError) as excinfo: - host.package("zsh").is_installed + host.package("zsh").is_installed # noqa: B018 assert ( "Could not check if RPM package 'zsh' is installed. error: sqlite failure:" ) in str(excinfo.value) @@ -80,7 +82,7 @@ def test_non_default_package_tool(host): @pytest.mark.destructive def test_uninstalled_package_version(host): with pytest.raises(AssertionError) as excinfo: - host.package("zsh").version + host.package("zsh").version # noqa: B018 assert ( "The package zsh is not installed, dpkg-query output: unknown ok not-installed" in str(excinfo.value) @@ -89,7 +91,7 @@ def test_uninstalled_package_version(host): host.check_output("apt-get -y remove sudo") assert not host.package("sudo").is_installed with pytest.raises(AssertionError) as excinfo: - host.package("sudo").version + host.package("sudo").version # noqa: B018 assert ( "The package sudo is not installed, dpkg-query output: " "deinstall ok config-files 1.9." @@ -112,11 +114,7 @@ def test_systeminfo(host, docker_image): @all_images def test_ssh_service(host, docker_image): - if docker_image == "rockylinux9": - name = "sshd" - else: - name = "ssh" - + name = "sshd" if docker_image == "rockylinux9" else "ssh" ssh = host.service(name) # wait at max 10 seconds for ssh is running for _ in range(10): @@ -194,7 +192,7 @@ def test_socket(host): assert not host.socket("tcp://4242").is_listening - if not host.backend.get_connection_type() == "docker": + if host.backend.get_connection_type() != "docker": # FIXME for spec in ( "tcp://22", @@ -232,6 +230,13 @@ def test_user(host): assert user.password == "!" +def test_user_get_all_users(host): + user_list = host.user("root").get_all_users + assert "root" in user_list + assert "man" in user_list + assert "nobody" in user_list + + def test_user_password_days(host): assert host.user("root").password_max_days == 99999 assert host.user("root").password_min_days == 0 @@ -247,7 +252,7 @@ def test_user_user(host): def test_user_expiration_date(host): assert host.user("root").expiration_date is None - assert host.user("user").expiration_date == (datetime.datetime(2024, 10, 4, 0, 0)) + assert host.user("user").expiration_date == datetime.datetime(2030, 1, 1) def test_nonexistent_user(host): @@ -257,12 +262,19 @@ def test_nonexistent_user(host): def test_current_user(host): assert host.user().name == "root" pw = host.user().password - assert crypt.crypt("foo", pw) == pw + assert pw.startswith("$") + assert len(pw) == 73 def test_group(host): assert host.group("root").exists assert host.group("root").gid == 0 + group_list = host.group("root").get_all_groups + assert "root" in group_list + assert "bin" in group_list + group_list = host.group("root").get_local_groups + assert "root" in group_list + assert "bin" in group_list def test_empty_command_output(host): @@ -308,7 +320,7 @@ def test_file(host): assert link.linked_to == "/d/f" assert link.linked_to == f assert f == host.file("/d/f") - assert not d == f + assert d != f host.check_output("ln /d/f /d/h") hardlink = host.file("/d/h") @@ -318,7 +330,7 @@ def test_file(host): assert isinstance(f.inode, int) assert hardlink.inode == f.inode assert f == host.file("/d/f") - assert not d == f + assert d != f host.check_output("rm -f /d/p && mkfifo /d/p") assert host.file("/d/p").is_pipe @@ -329,7 +341,7 @@ def test_file(host): def test_ansible_unavailable(host): - expected = "Ansible module is only available with " "ansible connection backend" + expected = "Ansible module is only available with ansible connection backend" with pytest.raises(RuntimeError) as excinfo: host.ansible("setup") assert expected in str(excinfo.value) @@ -354,17 +366,6 @@ def test_ansible_module(host): assert passwd["state"] in ("file", "hard") assert passwd["uid"] == 0 - variables = host.ansible.get_variables() - assert variables["myvar"] == "foo" - assert variables["myhostvar"] == "bar" - assert variables["mygroupvar"] == "qux" - assert variables["inventory_hostname"] == "debian_bookworm" - assert variables["group_names"] == ["all", "testgroup"] - assert variables["groups"] == { - "all": ["debian_bookworm"], - "testgroup": ["debian_bookworm"], - } - with pytest.raises(host.ansible.AnsibleException) as excinfo: host.ansible("command", "zzz") assert excinfo.value.result["msg"] == "Skipped. You might want to try check=False" @@ -374,12 +375,53 @@ def test_ansible_module(host): except host.ansible.AnsibleException as exc: assert exc.result["rc"] == 2 # notez que the debian bookworm container is set to LANG=fr_FR - assert exc.result["msg"] == ("[Errno 2] Aucun fichier ou dossier " "de ce type") + assert exc.result["msg"] == ("[Errno 2] Aucun fichier ou dossier de ce type") result = host.ansible("command", "echo foo", check=False) assert result["stdout"] == "foo" +@pytest.mark.testinfra_hosts("ansible://debian_bookworm") +def test_ansible_get_variables_flat_wo_child_groups(host): + """Test AnsibleRunner.get_variables() with parent groups only""" + variables = host.ansible.get_variables() + assert variables["myvar"] == "foo" + assert variables["myhostvar"] == "bar" + assert variables["mygroupvar"] == "qux" + assert variables["inventory_hostname"] == "debian_bookworm" + assert variables["group_names"] == ["all", "testgroup"] + assert variables["groups"] == { + "all": ["debian_bookworm"], + "testgroup": ["debian_bookworm"], + } + + +def test_ansible_get_variables_w_child_groups(): + """Test AnsibleRunner.get_variables() with parent and child groups""" + inventory = """ + host_a + [toplevel1] + host_b + [toplevel2] + host_c + [toplevel3:children] + toplevel1 + """ + with tempfile.NamedTemporaryFile(mode="wt", encoding="ascii") as file_inventory: + file_inventory.write(textwrap.dedent(inventory.strip())) + file_inventory.flush() + + get_variables = AnsibleRunner(file_inventory.name).get_variables + + assert get_variables("host_a")["group_names"] == ["all", "ungrouped"] + assert get_variables("host_b")["group_names"] == [ + "all", + "toplevel1", + "toplevel3", + ] + assert get_variables("host_c")["group_names"] == ["all", "toplevel2"] + + @pytest.mark.testinfra_hosts( "ansible://debian_bookworm", "ansible://user@debian_bookworm" ) @@ -439,9 +481,8 @@ def test_supervisor(host, supervisorctl_path, supervisorctl_conf): ) if service.status == "RUNNING": break - else: - assert service.status == "STARTING" - time.sleep(0.5) + assert service.status == "STARTING" + time.sleep(0.5) else: raise RuntimeError("No running tail in supervisor") @@ -471,7 +512,7 @@ def test_supervisor(host, supervisorctl_path, supervisorctl_conf): host.run("service supervisor stop") assert not host.service("supervisor").is_running with pytest.raises(RuntimeError) as excinfo: - host.supervisor( + host.supervisor( # noqa: B018 "tail", supervisorctl_path=supervisorctl_path, supervisorctl_conf=supervisorctl_conf, @@ -482,12 +523,14 @@ def test_supervisor(host, supervisorctl_path, supervisorctl_conf): def test_mountpoint(host): root_mount = host.mount_point("/") assert root_mount.exists + assert repr(root_mount) assert isinstance(root_mount.options, list) assert "rw" in root_mount.options assert root_mount.filesystem fake_mount = host.mount_point("/fake/mount") assert not fake_mount.exists + assert repr(fake_mount) mountpoints = host.mount_point.get_mountpoints() assert mountpoints @@ -504,9 +547,9 @@ def test_sudo_from_root(host): def test_sudo_fail_from_root(host): assert host.user().name == "root" - with pytest.raises(AssertionError) as exc: - with host.sudo("unprivileged"): - assert host.user().name == "unprivileged" + with host.sudo("unprivileged"): + assert host.user().name == "unprivileged" + with pytest.raises(AssertionError) as exc: host.check_output("ls /root/invalid") assert str(exc.value).startswith("Unexpected exit code") with host.sudo(): @@ -579,12 +622,9 @@ def test_ip6tables(host): try: v6_rules = host.iptables.rules(version=6) except AssertionError as exc_info: - if ( - "Perhaps ip6tables or your kernel needs to " - "be upgraded" in exc_info.args[0] - ): + if "Perhaps ip6tables or your kernel needs to be upgraded" in exc_info.args[0]: pytest.skip( - f"IPV6 does not seem to be enabled on the docker host" f"\n{exc_info}" + f"IPV6 does not seem to be enabled on the docker host\n{exc_info}" ) else: raise diff --git a/testinfra/backend/__init__.py b/testinfra/backend/__init__.py index e8bdf91f..e09583d4 100644 --- a/testinfra/backend/__init__.py +++ b/testinfra/backend/__init__.py @@ -13,7 +13,8 @@ import importlib import os import urllib.parse -from typing import TYPE_CHECKING, Any, Iterable +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: import testinfra.backend.base @@ -39,7 +40,7 @@ def get_backend_class(connection: str) -> type["testinfra.backend.base.BaseBacke try: classpath = BACKENDS[connection] except KeyError: - raise RuntimeError("Unknown connection type '{}'".format(connection)) + raise RuntimeError(f"Unknown connection type '{connection}'") from None module, name = classpath.rsplit(".", 1) return getattr(importlib.import_module(module), name) # type: ignore[no-any-return] @@ -108,9 +109,6 @@ def get_backends( key = (name, frozenset(kw.items())) if key in backends: continue - if connection == "local": - backend = klass(**kw) - else: - backend = klass(name, **kw) + backend = klass(**kw) if connection == "local" else klass(name, **kw) backends[key] = backend return list(backends.values()) diff --git a/testinfra/backend/ansible.py b/testinfra/backend/ansible.py index 61bf25f8..70079f76 100644 --- a/testinfra/backend/ansible.py +++ b/testinfra/backend/ansible.py @@ -10,6 +10,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import logging import pprint from typing import Any, Optional @@ -56,12 +57,15 @@ def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: if host is not None: return host.run(command) out = self.run_ansible("shell", module_args=command, check=False) - return self.result( - out["rc"], - self.encode(command), - out["stdout"], - out["stderr"], - ) + if "module_stdout" in out: + data = json.loads(out["module_stdout"]) + stdout = data["stdout"] + stderr = data["stderr"] + else: + # bw compat + stdout = out["stdout"] + stderr = out["stderr"] + return self.result(out["rc"], self.encode(command), stdout, stderr) def run_ansible( self, module_name: str, module_args: Optional[str] = None, **kwargs: Any diff --git a/testinfra/backend/base.py b/testinfra/backend/base.py index 2aefab94..afde01a8 100644 --- a/testinfra/backend/base.py +++ b/testinfra/backend/base.py @@ -146,7 +146,7 @@ def __init__( **kwargs: Any, ): self._encoding: Optional[str] = None - self._host: Optional["testinfra.host.Host"] = None + self._host: Optional[testinfra.host.Host] = None self.hostname = hostname self.sudo = sudo self.sudo_user = sudo_user @@ -197,16 +197,14 @@ def get_pytest_id(self) -> str: def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: if host is None: raise RuntimeError( - "One or more hosts is required with the {} backend".format( - cls.get_connection_type() - ) + f"One or more hosts is required with the {cls.get_connection_type()} backend" ) return [host] @staticmethod def quote(command: str, *args: str) -> str: if args: - return command % tuple(shlex.quote(a) for a in args) # noqa: S001 + return command % tuple(shlex.quote(a) for a in args) return command def get_sudo_command(self, command: str, sudo_user: Optional[str]) -> str: @@ -255,10 +253,7 @@ def parse_hostspec(hostspec: str) -> HostSpec: if name.startswith("["): name, port = name.split("]") name = name[1:] - if port.startswith(":"): - port = port[1:] - else: - port = None + port = port[1:] if port.startswith(":") else None else: if ":" in name: name, port = name.split(":", 1) diff --git a/testinfra/backend/chroot.py b/testinfra/backend/chroot.py index 57ea7d74..0af7a84f 100644 --- a/testinfra/backend/chroot.py +++ b/testinfra/backend/chroot.py @@ -30,9 +30,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any): def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: if not os.path.exists(self.name) and os.path.isdir(self.name): - raise RuntimeError( - "chroot path {} not found or not a directory".format(self.name) - ) + raise RuntimeError(f"chroot path {self.name} not found or not a directory") cmd = self.get_command(command, *args) out = self.run_local("chroot %s /bin/sh -c %s", self.name, cmd) out.command = self.encode(cmd) diff --git a/testinfra/backend/lxc.py b/testinfra/backend/lxc.py index a75e3b62..0db1e290 100644 --- a/testinfra/backend/lxc.py +++ b/testinfra/backend/lxc.py @@ -25,7 +25,7 @@ def __init__(self, name: str, *args: Any, **kwargs: Any): def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: cmd = self.get_command(command, *args) out = self.run_local( - "lxc exec %s --mode=non-interactive -- " "/bin/sh -c %s", self.name, cmd + "lxc exec %s --mode=non-interactive -- /bin/sh -c %s", self.name, cmd ) out.command = self.encode(cmd) return out diff --git a/testinfra/backend/paramiko.py b/testinfra/backend/paramiko.py index 4e5eae0b..9e8b3b4c 100644 --- a/testinfra/backend/paramiko.py +++ b/testinfra/backend/paramiko.py @@ -16,11 +16,9 @@ import paramiko except ImportError: raise RuntimeError( - ( - "You must install paramiko package (pip install paramiko) " - "to use the paramiko backend" - ) - ) + "You must install paramiko package (pip install paramiko) " + "to use the paramiko backend" + ) from None import functools from typing import Any, Optional @@ -121,7 +119,7 @@ def client(self) -> paramiko.SSHClient: with open(default_ssh_config) as f: ssh_config = paramiko.SSHConfig() ssh_config.parse(f) - except IOError: + except OSError: pass else: self._load_ssh_config(client, cfg, ssh_config, ssh_config_dir) @@ -138,9 +136,9 @@ def _exec_command(self, command: bytes) -> tuple[int, bytes, bytes]: if self.get_pty: chan.get_pty() chan.exec_command(command) - rc = chan.recv_exit_status() stdout = b"".join(chan.makefile("rb")) stderr = b"".join(chan.makefile_stderr("rb")) + rc = chan.recv_exit_status() return rc, stdout, stderr def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: diff --git a/testinfra/backend/salt.py b/testinfra/backend/salt.py index 6fadf210..5e779fc9 100644 --- a/testinfra/backend/salt.py +++ b/testinfra/backend/salt.py @@ -13,7 +13,9 @@ try: import salt.client except ImportError: - raise RuntimeError("You must install salt package to use the salt backend") + raise RuntimeError( + "You must install salt package to use the salt backend" + ) from None from typing import Any, Optional @@ -49,8 +51,7 @@ def run_salt(self, func: str, args: Any = None) -> Any: out = self.client.cmd(self.host, func, args or []) if self.host not in out: raise RuntimeError( - "Error while running {}({}): {}. " - "Minion not connected ?".format(func, args, out) + f"Error while running {func}({args}): {out}. Minion not connected ?" ) return out[self.host] @@ -65,6 +66,6 @@ def get_hosts(cls, host: str, **kwargs: Any) -> list[str]: else: hosts = client.cmd(host, "test.true").keys() if not hosts: - raise RuntimeError("No host matching '{}'".format(host)) + raise RuntimeError(f"No host matching '{host}'") return sorted(hosts) return super().get_hosts(host, **kwargs) diff --git a/testinfra/backend/ssh.py b/testinfra/backend/ssh.py index aae144d5..a1b2d53e 100644 --- a/testinfra/backend/ssh.py +++ b/testinfra/backend/ssh.py @@ -68,21 +68,19 @@ def _build_ssh_command(self, command: str) -> tuple[list[str], list[str]]: cmd.append("-i %s") cmd_args.append(self.ssh_identity_file) if "connecttimeout" not in (self.ssh_extra_args or "").lower(): - cmd.append("-o ConnectTimeout={}".format(self.timeout)) + cmd.append(f"-o ConnectTimeout={self.timeout}") if self.controlpersist and ( "controlmaster" not in (self.ssh_extra_args or "").lower() ): cmd.append( - "-o ControlMaster=auto -o ControlPersist={}s".format( - self.controlpersist - ) + f"-o ControlMaster=auto -o ControlPersist={self.controlpersist}s" ) if ( "ControlMaster" in " ".join(cmd) and self.controlpath and ("controlpath" not in (self.ssh_extra_args or "").lower()) ): - cmd.append("-o ControlPath={}".format(self.controlpath)) + cmd.append(f"-o ControlPath={self.controlpath}") cmd.append("%s %s") cmd_args.extend([self.host.name, command]) return cmd, cmd_args @@ -120,11 +118,9 @@ def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: orig_command = self.get_command("sh -c %s", orig_command) out = self.run_ssh( - ( - """of=$(mktemp)&&ef=$(mktemp)&&{} >$of 2>$ef; r=$?;""" - """echo "TESTINFRA_START;$r;$(base64 < $of);$(base64 < $ef);""" - """TESTINFRA_END";rm -f $of $ef""" - ).format(orig_command) + f"""of=$(mktemp)&&ef=$(mktemp)&&{orig_command} >$of 2>$ef; r=$?;""" + """echo "TESTINFRA_START;$r;$(base64 < $of);$(base64 < $ef);""" + """TESTINFRA_END";rm -f $of $ef""" ) start = out.stdout.find("TESTINFRA_START;") + len("TESTINFRA_START;") diff --git a/testinfra/backend/winrm.py b/testinfra/backend/winrm.py index c6ce3bf6..563b50b2 100644 --- a/testinfra/backend/winrm.py +++ b/testinfra/backend/winrm.py @@ -19,11 +19,9 @@ import winrm except ImportError: raise RuntimeError( - ( - "You must install the pywinrm package (pip install pywinrm) " - "to use the winrm backend" - ) - ) + "You must install the pywinrm package (pip install pywinrm) " + "to use the winrm backend" + ) from None import winrm.protocol @@ -64,7 +62,7 @@ def __init__( "endpoint": "{}://{}{}/wsman".format( "http" if no_ssl else "https", self.host.name, - ":{}".format(self.host.port) if self.host.port else "", + f":{self.host.port}" if self.host.port else "", ), "transport": "ntlm", "username": self.host.user, @@ -81,10 +79,10 @@ def __init__( def run(self, command: str, *args: str, **kwargs: Any) -> base.CommandResult: return self.run_winrm(self.get_command(command, *args)) - def run_winrm(self, command: str, *args: str) -> base.CommandResult: + def run_winrm(self, command: str) -> base.CommandResult: p = winrm.protocol.Protocol(**self.conn_args) shell_id = p.open_shell() - command_id = p.run_command(shell_id, command, *args) + command_id = p.run_command(shell_id, command) stdout, stderr, rc = p.get_command_output(shell_id, command_id) p.cleanup_command(shell_id, command_id) p.close_shell(shell_id) @@ -93,5 +91,5 @@ def run_winrm(self, command: str, *args: str) -> base.CommandResult: @staticmethod def quote(command: str, *args: str) -> str: if args: - return command % tuple(_quote(a) for a in args) # noqa: S001 + return command % tuple(_quote(a) for a in args) return command diff --git a/testinfra/host.py b/testinfra/host.py index 9aac5cd4..6c888fb3 100644 --- a/testinfra/host.py +++ b/testinfra/host.py @@ -32,7 +32,7 @@ def __init__(self, backend: testinfra.backend.base.BaseBackend): super().__init__() def __repr__(self) -> str: - return "".format(self.backend.get_pytest_id()) + return f"" @functools.cached_property def has_command_v(self) -> bool: @@ -64,7 +64,7 @@ def find_command( path = os.path.join(basedir, command) if self.exists(path): return path - raise ValueError('cannot find "{}" command'.format(command)) + raise ValueError(f'cannot find "{command}" command') def run( self, command: str, *args: str, **kwargs: Any @@ -107,7 +107,7 @@ def run_expect( """ __tracebackhide__ = True out = self.run(command, *args, **kwargs) - assert out.rc in expected, "Unexpected exit code {} for {}".format(out.rc, out) + assert out.rc in expected, f"Unexpected exit code {out.rc} for {out}" return out def run_test( @@ -127,7 +127,7 @@ def check_output(self, command: str, *args: str, **kwargs: Any) -> str: """ __tracebackhide__ = True out = self.run(command, *args, **kwargs) - assert out.rc == 0, "Unexpected exit code {} for {}".format(out.rc, out) + assert out.rc == 0, f"Unexpected exit code {out.rc} for {out}" return out.stdout.rstrip("\r\n") def __getattr__(self, name: str) -> type[testinfra.modules.base.Module]: @@ -137,7 +137,7 @@ def __getattr__(self, name: str) -> type[testinfra.modules.base.Module]: setattr(self, name, obj) return obj raise AttributeError( - "'{}' object has no attribute '{}'".format(self.__class__.__name__, name) + f"'{self.__class__.__name__}' object has no attribute '{name}'" ) @classmethod diff --git a/testinfra/main.py b/testinfra/main.py index 3a9c8b03..4d11d429 100644 --- a/testinfra/main.py +++ b/testinfra/main.py @@ -16,5 +16,5 @@ def main() -> int: - warnings.warn("calling testinfra is deprecated, call py.test instead", stacklevel=1) + warnings.warn("calling testinfra is deprecated, call pytest instead", stacklevel=1) return pytest.main() diff --git a/testinfra/modules/addr.py b/testinfra/modules/addr.py index a15142c3..653042b7 100644 --- a/testinfra/modules/addr.py +++ b/testinfra/modules/addr.py @@ -40,9 +40,7 @@ def is_reachable(self): return ( self._addr.run( - "{}nc -w 1 -z {} {}".format( - self._addr._prefix, self._addr.name, self._port - ) + f"{self._addr._prefix}nc -w 1 -z {self._addr.name} {self._port}" ).rc == 0 ) @@ -104,7 +102,7 @@ def _prefix(self): """Return the prefix to use for commands""" prefix = "" if self.namespace: - prefix = "ip netns exec {} ".format(self.namespace) + prefix = f"ip netns exec {self.namespace} " return prefix @property @@ -125,9 +123,7 @@ def is_resolvable(self): def is_reachable(self): """Return if address is reachable""" return ( - self.run_expect( - [0, 1, 2], "{}ping -W 1 -c 1 {}".format(self._prefix, self.name) - ).rc + self.run_expect([0, 1, 2], f"{self._prefix}ping -W 1 -c 1 {self.name}").rc == 0 ) @@ -151,11 +147,11 @@ def port(self, port): return _AddrPort(self, port) def __repr__(self): - return "".format(self.name) + return f"" def _resolve(self, method): result = self.run_expect( - [0, 1, 2], "{}getent {} {}".format(self._prefix, method, self.name) + [0, 1, 2], f"{self._prefix}getent {method} {self.name}" ) lines = result.stdout.splitlines() return list({line.split()[0] for line in lines}) diff --git a/testinfra/modules/ansible.py b/testinfra/modules/ansible.py index 033992e2..8997a23f 100644 --- a/testinfra/modules/ansible.py +++ b/testinfra/modules/ansible.py @@ -30,7 +30,7 @@ class AnsibleException(Exception): def __init__(self, result): self.result = result - super().__init__("Unexpected error: {}".format(pprint.pformat(result))) + super().__init__(f"Unexpected error: {pprint.pformat(result)}") def need_ansible(func): @@ -38,7 +38,7 @@ def need_ansible(func): def wrapper(self, *args, **kwargs): if not self._host.backend.HAS_RUN_ANSIBLE: raise RuntimeError( - ("Ansible module is only available with ansible " "connection backend") + "Ansible module is only available with ansible connection backend" ) return func(self, *args, **kwargs) diff --git a/testinfra/modules/blockdevice.py b/testinfra/modules/blockdevice.py index fdfd9e0c..e3a47081 100644 --- a/testinfra/modules/blockdevice.py +++ b/testinfra/modules/blockdevice.py @@ -103,7 +103,7 @@ def is_writable(self): return True if mode == "ro": return False - raise ValueError("Unexpected value for rw: {}".format(mode)) + raise ValueError(f"Unexpected value for rw: {mode}") @property def ra(self): @@ -121,7 +121,7 @@ def get_module_class(cls, host): raise NotImplementedError def __repr__(self): - return "".format(self.device) + return f"" class LinuxBlockDevice(BlockDevice): @@ -131,12 +131,12 @@ def _data(self): command = "blockdev --report %s" blockdev = self.run(command, self.device) if blockdev.rc != 0: - raise RuntimeError("Failed to gather data: {}".format(blockdev.stderr)) + raise RuntimeError(f"Failed to gather data: {blockdev.stderr}") output = blockdev.stdout.splitlines() if len(output) < 2: - raise RuntimeError("No data from {}".format(self.device)) + raise RuntimeError(f"No data from {self.device}") if output[0].split() != header: - raise RuntimeError("Unknown output of blockdev: {}".format(output[0])) + raise RuntimeError(f"Unknown output of blockdev: {output[0]}") fields = output[1].split() return { "rw_mode": str(fields[0]), diff --git a/testinfra/modules/docker.py b/testinfra/modules/docker.py index 8a2b8049..ba81c0bd 100644 --- a/testinfra/modules/docker.py +++ b/testinfra/modules/docker.py @@ -15,7 +15,6 @@ class Docker(Module): - """Test docker containers running on system. Example: @@ -77,7 +76,7 @@ def version(cls, format=None): """ cmd = "docker version" if format: - cmd = "{} --format '{}'".format(cmd, format) + cmd = f"{cmd} --format '{format}'" return cls.check_output(cmd) @classmethod @@ -108,10 +107,7 @@ def get_containers(cls, **filters): cmd = "docker ps --all --quiet --format '{{.Names}}'" args = [] for key, value in filters.items(): - if isinstance(value, (list, tuple)): - values = value - else: - values = [value] + values = value if isinstance(value, (list, tuple)) else [value] for v in values: cmd += " --filter %s=%s" args += [key, v] @@ -121,4 +117,4 @@ def get_containers(cls, **filters): return result def __repr__(self): - return "".format(self._name) + return f"" diff --git a/testinfra/modules/file.py b/testinfra/modules/file.py index 13307f49..bf86bc10 100644 --- a/testinfra/modules/file.py +++ b/testinfra/modules/file.py @@ -124,7 +124,7 @@ def mode(self): .. _oct(x): https://docs.python.org/3/library/functions.html#oct .. _stat: https://docs.python.org/3/library/stat.html - """ # noqa + """ raise NotImplementedError def contains(self, pattern): @@ -147,7 +147,7 @@ def sha256sum(self): def _get_content(self, decode): out = self.run_test("cat -- %s", self.path) if out.rc != 0: - raise RuntimeError("Unexpected output {}".format(out)) + raise RuntimeError(f"Unexpected output {out}") if decode: return out.stdout return out.stdout_bytes @@ -192,11 +192,11 @@ def listdir(self): """ out = self.run_test("ls -1 -q -- %s", self.path) if out.rc != 0: - raise RuntimeError("Unexpected output {}".format(out)) + raise RuntimeError(f"Unexpected output {out}") return out.stdout.splitlines() def __repr__(self): - return "".format(self.path) + return f"" def __eq__(self, other): if isinstance(other, File): @@ -309,8 +309,8 @@ def sha256sum(self): class DarwinFile(BSDFile): @property def linked_to(self): - link_script = """ - TARGET_FILE='{0}' + link_script = f""" + TARGET_FILE='{self.path}' cd `dirname $TARGET_FILE` TARGET_FILE=`basename $TARGET_FILE` while [ -L "$TARGET_FILE" ] @@ -322,9 +322,7 @@ def linked_to(self): PHYS_DIR=`pwd -P` RESULT=$PHYS_DIR/$TARGET_FILE echo $RESULT - """.format( - self.path - ) + """ return self.check_output(link_script) diff --git a/testinfra/modules/group.py b/testinfra/modules/group.py index e87c7048..48c735d0 100644 --- a/testinfra/modules/group.py +++ b/testinfra/modules/group.py @@ -31,6 +31,32 @@ def exists(self): """ return self.run_expect([0, 2], "getent group %s", self.name).rc == 0 + @property + def get_all_groups(self): + """Returns a list of local and remote group names + + >>> host.group("anyname").get_all_groups + ["root", "wheel", "man", "tty", <...>] + """ + all_groups = [ + line.split(":")[0] + for line in self.check_output("getent group").splitlines() + ] + return all_groups + + @property + def get_local_groups(self): + """Returns a list of local group names + + >>> host.group("anyname").get_local_groups + ["root", "wheel", "man", "tty", <...>] + """ + local_groups = [ + line.split(":")[0] + for line in self.check_output("cat /etc/group").splitlines() + ] + return local_groups + @property def gid(self): return int(self.check_output("getent group %s | cut -d':' -f3", self.name)) @@ -44,4 +70,4 @@ def members(self): return [] def __repr__(self): - return "".format(self.name) + return f"" diff --git a/testinfra/modules/interface.py b/testinfra/modules/interface.py index ce6b3a3b..b36b8e61 100644 --- a/testinfra/modules/interface.py +++ b/testinfra/modules/interface.py @@ -92,7 +92,7 @@ def routes(self, scope=None): raise NotImplementedError def __repr__(self): - return "".format(self.name) + return f"" @classmethod def get_module_class(cls, host): @@ -136,7 +136,7 @@ def _ip(self): @property def exists(self): - return self.run_test("{} link show %s".format(self._ip), self.name).rc == 0 + return self.run_test(f"{self._ip} link show %s", self.name).rc == 0 @property def speed(self): @@ -144,7 +144,7 @@ def speed(self): @property def addresses(self): - stdout = self.check_output("{} addr show %s".format(self._ip), self.name) + stdout = self.check_output(f"{self._ip} addr show %s", self.name) addrs = [] for line in stdout.splitlines(): splitted = [e.strip() for e in line.split(" ") if e] @@ -171,7 +171,7 @@ def routes(self, scope=None): @classmethod def default(cls, family=None): _default = cls(None, family=family) - out = cls.check_output("{} route ls".format(_default._ip)) + out = cls.check_output(f"{_default._ip} route ls") for line in out.splitlines(): if "default" in line: match = re.search(r"dev\s(\S+)", line) @@ -182,7 +182,7 @@ def default(cls, family=None): @classmethod def names(cls): # -o is to tell the ip command to return 1 line per interface - out = cls.check_output("{} -o link show".format(cls(None)._ip)) + out = cls.check_output(f"{cls(None)._ip} -o link show") interfaces = [] for line in out.splitlines(): interfaces.append(line.strip().split(": ", 2)[1].split("@", 1)[0]) diff --git a/testinfra/modules/iptables.py b/testinfra/modules/iptables.py index dc5bd4cf..fa053ea1 100644 --- a/testinfra/modules/iptables.py +++ b/testinfra/modules/iptables.py @@ -30,14 +30,14 @@ def _iptables_command(self, version): elif version == 6: iptables = "ip6tables" else: - raise RuntimeError("Invalid version: {}".format(version)) + raise RuntimeError(f"Invalid version: {version}") if self._has_w_argument is False: return iptables else: - return "{} -w 90".format(iptables) + return f"{iptables} -w 90" def _run_iptables(self, version, cmd, *args): - ipt_cmd = "{} {}".format(self._iptables_command(version), cmd) + ipt_cmd = f"{self._iptables_command(version)} {cmd}" if self._has_w_argument is None: result = self.run_expect([0, 2], ipt_cmd, *args) if result.rc == 2: diff --git a/testinfra/modules/mountpoint.py b/testinfra/modules/mountpoint.py index 4fd5369e..05d87813 100644 --- a/testinfra/modules/mountpoint.py +++ b/testinfra/modules/mountpoint.py @@ -86,7 +86,7 @@ def get_mountpoints(cls): >>> host.mount_point.get_mountpoints() [, ] - """ # noqa + """ mountpoints = [] for mountpoint in cls._iter_mountpoints(): mountpoints.append(cls(mountpoint["path"], mountpoint)) @@ -101,13 +101,17 @@ def get_module_class(cls, host): raise NotImplementedError def __repr__(self): + if self.exists: + d = self.device + f = self.filesystem + o = ",".join(self.options) + else: + d = "" + f = "" + o = "" return ( - "" - ).format( - self.path, - self.device, - self.filesystem, - ",".join(self.options), + f'' ) diff --git a/testinfra/modules/package.py b/testinfra/modules/package.py index f9f93515..0e424a53 100644 --- a/testinfra/modules/package.py +++ b/testinfra/modules/package.py @@ -60,7 +60,7 @@ def version(self): raise NotImplementedError def __repr__(self): - return "".format(self.name) + return f"" @classmethod def get_module_class(cls, host): @@ -122,9 +122,7 @@ def version(self): assert splitted[0].lower() in ( "install", "hold", - ), "The package {} is not installed, dpkg-query output: {}".format( - self.name, out - ) + ), f"The package {self.name} is not installed, dpkg-query output: {out}" return splitted[3] @@ -148,7 +146,7 @@ def version(self): class OpenBSDPackage(Package): @property def is_installed(self): - return self.run_test("pkg_info -e %s", "{}-*".format(self.name)).rc == 0 + return self.run_test("pkg_info -e %s", f"{self.name}-*").rc == 0 @property def release(self): @@ -156,7 +154,7 @@ def release(self): @property def version(self): - out = self.check_output("pkg_info -e %s", "{}-*".format(self.name)) + out = self.check_output("pkg_info -e %s", f"{self.name}-*") # OpenBSD: inst:zsh-5.0.5p0 # NetBSD: zsh-5.0.7nb1 return out.split(self.name + "-", 1)[1] diff --git a/testinfra/modules/pip.py b/testinfra/modules/pip.py index f6b21c28..257e5f8e 100644 --- a/testinfra/modules/pip.py +++ b/testinfra/modules/pip.py @@ -19,7 +19,7 @@ def _re_match(line, regexp): match = regexp.match(line) if match is None: - raise RuntimeError("could not parse {0}".format(line)) + raise RuntimeError(f"could not parse {line}") return match.groups() diff --git a/testinfra/modules/podman.py b/testinfra/modules/podman.py index af3f232d..0297587c 100644 --- a/testinfra/modules/podman.py +++ b/testinfra/modules/podman.py @@ -15,7 +15,6 @@ class Podman(Module): - """Test podman containers running on system. Example: @@ -77,10 +76,7 @@ def get_containers(cls, **filters): cmd = "podman ps --all --format '{{.Names}}'" args = [] for key, value in filters.items(): - if isinstance(value, (list, tuple)): - values = value - else: - values = [value] + values = value if isinstance(value, (list, tuple)) else [value] for v in values: cmd += " --filter %s=%s" args += [key, v] @@ -90,4 +86,4 @@ def get_containers(cls, **filters): return result def __repr__(self): - return "".format(self._name) + return f"" diff --git a/testinfra/modules/process.py b/testinfra/modules/process.py index a5901de7..faa6650e 100644 --- a/testinfra/modules/process.py +++ b/testinfra/modules/process.py @@ -38,7 +38,7 @@ def __getattr__(self, key): " This mean the process you are working on does not not " "exist anymore" ).format(self["pid"], self["lstart"], attrs["lstart"]) - ) + ) from None return attrs[key] def __repr__(self): @@ -82,9 +82,9 @@ def filter(self, **filters): if str(attrs[key]) != str(value): break else: - attrs[ - "_get_process_attribute_by_pid" - ] = self._get_process_attribute_by_pid + attrs["_get_process_attribute_by_pid"] = ( + self._get_process_attribute_by_pid + ) match.append(_Process(attrs)) return match @@ -98,7 +98,7 @@ def get(self, **filters): if not matches: raise RuntimeError("No process found") if len(matches) > 1: - raise RuntimeError("Multiple process found: {}".format(matches)) + raise RuntimeError(f"Multiple process found: {matches}") return matches[0] def _get_processes(self, **filters): @@ -111,9 +111,11 @@ def _get_process_attribute_by_pid(self, pid, name): def get_module_class(cls, host): if host.file("/bin/ps").linked_to == "/bin/busybox": return BusyboxProcess - if host.file("/bin/busybox").exists: - if host.file("/bin/ps").inode == host.file("/bin/busybox").inode: - return BusyboxProcess + if ( + host.file("/bin/busybox").exists + and host.file("/bin/ps").inode == host.file("/bin/busybox").inode + ): + return BusyboxProcess if host.system_info.type == "linux" or host.system_info.type.endswith("bsd"): return PosixProcess raise NotImplementedError diff --git a/testinfra/modules/puppet.py b/testinfra/modules/puppet.py index a5aa414a..684a53b5 100644 --- a/testinfra/modules/puppet.py +++ b/testinfra/modules/puppet.py @@ -11,12 +11,11 @@ # limitations under the License. import json -from typing import Dict from testinfra.modules.base import InstanceModule -def parse_puppet_resource(data: str) -> Dict[str, Dict[str, str]]: +def parse_puppet_resource(data: str) -> dict[str, dict[str, str]]: """Parse data returned by 'puppet resource' $ puppet resource user @@ -38,7 +37,7 @@ def parse_puppet_resource(data: str) -> Dict[str, Dict[str, str]]: [...] """ - state: Dict[str, Dict[str, str]] = {} + state: dict[str, dict[str, str]] = {} current = None for line in data.splitlines(): if not current: diff --git a/testinfra/modules/service.py b/testinfra/modules/service.py index 9a3d704a..62f78b43 100644 --- a/testinfra/modules/service.py +++ b/testinfra/modules/service.py @@ -114,7 +114,7 @@ def get_module_class(cls, host): raise NotImplementedError def __repr__(self): - return "".format(self.name) + return f"" class SysvService(Service): @@ -181,7 +181,12 @@ def exists(self): @property def is_running(self): - out = self.run_expect([0, 1, 3], "systemctl is-active %s", self.name) + # based on https://man7.org/linux/man-pages/man1/systemctl.1.html + # 0: program running + # 1: program is dead and pid file exists + # 3: not running and pid file does not exists + # 4: Unable to determine status (no such unit) + out = self.run_expect([0, 1, 3, 4], "systemctl is-active %s", self.name) if out.rc == 1: # Failed to connect to bus: No such file or directory return super().is_running @@ -199,18 +204,13 @@ def is_enabled(self): if not self._has_systemd_suffix(): return super().is_enabled raise RuntimeError( - "Unable to determine state of {0}. Does this service exist?".format( - self.name - ) + f"Unable to determine state of {self.name}. Does this service exist?" ) @property def is_valid(self): # systemd-analyze requires a full unit name. - if self._has_systemd_suffix(): - name = self.name - else: - name = self.name + ".service" + name = self.name if self._has_systemd_suffix() else f"{self.name}.service" cmd = self.run("systemd-analyze verify %s", name) # A bad unit file still returns a rc of 0, so check the # stdout for anything. Nothing means no warns/errors. diff --git a/testinfra/modules/socket.py b/testinfra/modules/socket.py index 0b84cde6..69b3c038 100644 --- a/testinfra/modules/socket.py +++ b/testinfra/modules/socket.py @@ -12,7 +12,7 @@ import functools import socket -from typing import List, Optional, Tuple +from typing import Optional from testinfra.modules.base import Module @@ -22,7 +22,7 @@ def parse_socketspec(socketspec): if protocol not in ("udp", "tcp", "unix"): raise RuntimeError( - "Cannot validate protocol '{}'. Should be tcp, udp or unix".format(protocol) + f"Cannot validate protocol '{protocol}'. Should be tcp, udp or unix" ) if protocol == "unix": @@ -43,20 +43,20 @@ def parse_socketspec(socketspec): for f in (socket.AF_INET, socket.AF_INET6): try: socket.inet_pton(f, host) - except socket.error: + except OSError: pass else: family = f break if family is None: - raise RuntimeError("Cannot validate ip address '{}'".format(host)) + raise RuntimeError(f"Cannot validate ip address '{host}'") if port is not None: try: port = int(port) except ValueError: - raise RuntimeError("Cannot validate port '{}'".format(port)) + raise RuntimeError(f"Cannot validate port '{port}'") from None return protocol, host, port @@ -121,7 +121,7 @@ def is_listening(self): ) @property - def clients(self) -> List[Optional[Tuple[str, int]]]: + def clients(self) -> list[Optional[tuple[str, int]]]: """Return a list of clients connected to a listening socket For tcp and udp sockets a list of pair (address, port) is returned. @@ -134,7 +134,7 @@ def clients(self) -> List[Optional[Tuple[str, int]]]: [None, None, None] """ - sockets: List[Optional[Tuple[str, int]]] = [] + sockets: list[Optional[tuple[str, int]]] = [] for sock in self._iter_sockets(False): if sock[0] != self.protocol: continue @@ -168,13 +168,7 @@ def get_listening_sockets(cls): if sock[0] == "unix": sockets.append("unix://" + sock[1]) else: - sockets.append( - "{}://{}:{}".format( - sock[0], - sock[1], - sock[2], - ) - ) + sockets.append(f"{sock[0]}://{sock[1]}:{sock[2]}") return sockets def _iter_sockets(self, listening): @@ -342,11 +336,7 @@ def _iter_sockets(self, listening): port = int(port) if host == "*": - if splitted[0] in ("udp6", "tcp6"): - host = "::" - else: - host = "0.0.0.0" - + host = "::" if splitted[0] in ("udp6", "tcp6") else "0.0.0.0" if splitted[0] in ("udp", "udp6", "udp4"): protocol = "udp" elif splitted[0] in ("tcp", "tcp6", "tcp4"): diff --git a/testinfra/modules/supervisor.py b/testinfra/modules/supervisor.py index 58df1ac2..7afb36b9 100644 --- a/testinfra/modules/supervisor.py +++ b/testinfra/modules/supervisor.py @@ -71,14 +71,7 @@ def _parse_status(line): # check that parsed status is a known status. if status not in STATUS: supervisor_not_running() - if status == "RUNNING": - pid = splitted[3] - if pid[-1] == ",": - pid = int(pid[:-1]) - else: - pid = int(pid) - else: - pid = None + pid = int(splitted[3].removesuffix(",")) if status == "RUNNING" else None return {"name": name, "status": status, "pid": pid} @property @@ -162,8 +155,4 @@ def get_services( return services def __repr__(self): - return "".format( - self.name, - self.status, - self.pid, - ) + return f"" diff --git a/testinfra/modules/user.py b/testinfra/modules/user.py index 1e1c95f2..936876f3 100644 --- a/testinfra/modules/user.py +++ b/testinfra/modules/user.py @@ -133,6 +133,34 @@ def expiration_date(self): epoch = datetime.datetime.utcfromtimestamp(0) return epoch + datetime.timedelta(days=int(days)) + @property + def get_all_users(self): + """Returns a list of local and remote user names + + >>> host.user().get_all_users + ["root", "bin", "daemon", "lp", <...>] + """ + all_users = [ + line.split(":")[0] + for line in self.check_output("getent passwd").splitlines() + ] + return all_users + + @property + def get_local_users(self): + """Returns a list of local user names + + >>> host.user().get_local_users + ["root", "bin", "daemon", "lp", <...>] + """ + local_users = [ + line.split(":")[0] + for line in self.check_output("cat /etc/passwd").splitlines() + ] + # strip NIS compat mode entries + local_users = [i for i in local_users if not i.startswith("+")] + return local_users + @classmethod def get_module_class(cls, host): if host.system_info.type.endswith("bsd"): @@ -142,7 +170,7 @@ def get_module_class(cls, host): return super().get_module_class(host) def __repr__(self): - return "".format(self.name) + return f"" class BSDUser(User): @@ -204,7 +232,7 @@ def gids(self): def groups(self): """Return the list of user local group names""" local_groups = self.check_output( - 'net user %s | findstr /B /C:"Local ' 'Group Memberships"', self.name + 'net user %s | findstr /B /C:"Local Group Memberships"', self.name ) local_groups = local_groups.split()[3:] return [g.replace("*", "") for g in local_groups] diff --git a/testinfra/plugin.py b/testinfra/plugin.py index db3541b0..686fd342 100644 --- a/testinfra/plugin.py +++ b/testinfra/plugin.py @@ -162,7 +162,7 @@ def report(self) -> int: out = sys.stdout.buffer out.write( - (b"TESTINFRA %s - %d passed, %d failed, %d skipped in %.2f " b"seconds\n") + (b"TESTINFRA %s - %d passed, %d failed, %d skipped in %.2f seconds\n") % ( status, self.passed, diff --git a/testinfra/utils/ansible_runner.py b/testinfra/utils/ansible_runner.py index dc315141..89b6c8cf 100644 --- a/testinfra/utils/ansible_runner.py +++ b/testinfra/utils/ansible_runner.py @@ -17,7 +17,8 @@ import json import os import tempfile -from typing import Any, Callable, Iterator, Optional, Union +from collections.abc import Iterator +from typing import Any, Callable, Optional, Union import testinfra import testinfra.host @@ -194,19 +195,17 @@ def get_config( control_path_dir = os.path.normpath(control_path_dir) if os.path.isdir(control_path_dir): - control_path = control_path % ( # noqa: S001 - {"directory": control_path_dir} - ) + control_path = control_path % ({"directory": control_path_dir}) control_path = control_path.replace("%", "%%") # restore original "%%" kwargs["controlpath"] = control_path - spec = "{}://".format(connection) + spec = f"{connection}://" # Fallback to user:password auth when identity file is not used if user and password and not kwargs.get("ssh_identity_file"): - spec += "{}:{}@".format(user, password) + spec += f"{user}:{password}@" elif user: - spec += "{}@".format(user) + spec += f"{user}@" try: version = ipaddress.ip_address(testinfra_host).version @@ -217,7 +216,7 @@ def get_config( else: spec += testinfra_host if port: - spec += ":{}".format(port) + spec += f":{port}" return testinfra.get_host(spec, **kwargs) @@ -363,7 +362,7 @@ def options_to_cli(self, options: dict[str, Any]) -> tuple[str, list[str]]: value_json = json.dumps(value) cli_args.append(value_json) else: - raise TypeError("Unsupported argument type '{}'.".format(opt_type)) + raise TypeError(f"Unsupported argument type '{opt_type}'.") return " ".join(cli), cli_args def run_module( @@ -403,12 +402,12 @@ def run_module( raise RuntimeError(f"{out}") fpath = os.path.join(d, files[0]) try: - with open(fpath, "r", encoding="ascii") as f: + with open(fpath, encoding="ascii") as f: return json.load(f) except UnicodeDecodeError: if get_encoding is None: raise - with open(fpath, "r", encoding=get_encoding()) as f: + with open(fpath, encoding=get_encoding()) as f: return json.load(f) @classmethod diff --git a/tox.ini b/tox.ini index 25c92418..5248ae87 100644 --- a/tox.ini +++ b/tox.ini @@ -2,44 +2,54 @@ minversion = 4.0.16 envlist= lint + mypy py docs packaging [testenv] description = Runs unittests -deps= - -rtest-requirements.txt +extras = + test + typing commands= - {envpython} -m pytest {posargs:-v -n 4 --cov testinfra --cov-report xml --cov-report term test} + pytest {posargs:-v -n 4 --cov testinfra --cov-report xml --cov-report term test} usedevelop=True passenv= - HOME - TRAVIS - DOCKER_CERT_PATH - DOCKER_HOST - DOCKER_TLS_VERIFY - WSL_DISTRO_NAME + HOME + TRAVIS + DOCKER_CERT_PATH + DOCKER_HOST + DOCKER_TLS_VERIFY + WSL_DISTRO_NAME [testenv:lint] description = Performs linting tasks +skip_install = true deps = - pre-commit>=2.6.0 + pre-commit>=2.6.0 + ruff commands= pre-commit run -a +[testenv:mypy] +description = Performs typing check +extras = + typing +usedevelop=True +commands= + mypy + [testenv:docs] -deps=-rdev-requirements.txt -commands=sphinx-build -W -b html doc/source doc/build +extras = + doc +commands= + sphinx-build -W -b html doc/source doc/build [testenv:packaging] description = Validate project packaging skip_install = true -setenv = - PEP440_VERSION=true deps= - check-manifest + build commands= - {envpython} -m check_manifest {toxinidir} - {envpython} setup.py sdist - {envpython} setup.py bdist_wheel + {envpython} -m build