summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-03 13:38:37 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-06-03 13:38:37 +0000
commit65e5308e14a4ea5b6cc70aab90c8617812583da2 (patch)
tree1d5498df58f93c6ed30ae47da7b86d392a79aa0f
parentAdding debian version 24.5.1-1. (diff)
downloadpython-ansible-compat-65e5308e14a4ea5b6cc70aab90c8617812583da2.tar.xz
python-ansible-compat-65e5308e14a4ea5b6cc70aab90c8617812583da2.zip
Merging upstream version 24.6.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
-rw-r--r--.config/python3-ansible-compat.spec66
-rw-r--r--.config/requirements.in2
-rw-r--r--.git_archival.txt8
-rw-r--r--.github/workflows/tox.yml94
-rw-r--r--.gitignore2
-rw-r--r--.packit.yaml14
-rw-r--r--.pre-commit-config.yaml9
-rw-r--r--.vscode/extensions.json16
-rw-r--r--.vscode/settings.json22
-rw-r--r--README.md2
-rw-r--r--pyproject.toml273
-rw-r--r--src/ansible_compat/config.py63
-rw-r--r--src/ansible_compat/constants.py2
-rw-r--r--src/ansible_compat/runtime.py89
-rw-r--r--test/roles/acme.missing_deps/requirements.yml2
-rw-r--r--test/test_config.py21
-rw-r--r--test/test_runtime.py191
-rw-r--r--test/test_schema.py13
-rw-r--r--test/test_types.py9
-rw-r--r--test/test_version.py13
-rwxr-xr-xtools/get-version.sh9
-rwxr-xr-xtools/update-spec.sh15
-rwxr-xr-xtools/update-version.sh7
-rw-r--r--tox.ini49
24 files changed, 752 insertions, 239 deletions
diff --git a/.config/python3-ansible-compat.spec b/.config/python3-ansible-compat.spec
new file mode 100644
index 0000000..362b563
--- /dev/null
+++ b/.config/python3-ansible-compat.spec
@@ -0,0 +1,66 @@
+# spell-checker:ignore bcond pkgversion buildrequires autosetup PYTHONPATH noarch buildroot bindir sitelib numprocesses clib
+# All tests require Internet access
+# to test in mock use: --enable-network --with check
+# to test in a privileged environment use:
+# --with check --with privileged_tests
+%bcond_with check
+%bcond_with privileged_tests
+
+Name: ansible-compat
+Version: VERSION_PLACEHOLDER
+Release: 1%{?dist}
+Summary: Ansible-compat library
+
+License: GPL-3.0-or-later
+URL: https://github.com/ansible/ansible-compat
+Source0: %{pypi_source}
+
+BuildArch: noarch
+
+BuildRequires: python%{python3_pkgversion}-devel
+%if %{with check}
+# These are required for tests:
+BuildRequires: python%{python3_pkgversion}-pytest
+BuildRequires: python%{python3_pkgversion}-pytest-xdist
+BuildRequires: python%{python3_pkgversion}-libselinux
+BuildRequires: git-core
+%endif
+Requires: git-core
+
+
+%description
+Ansible-compat.
+
+%prep
+%autosetup
+
+
+%generate_buildrequires
+%pyproject_buildrequires
+
+
+%build
+%pyproject_wheel
+
+
+%install
+%pyproject_install
+%pyproject_save_files ansible_compat
+
+
+%check
+%pyproject_check_import
+%if %{with check}
+%pytest \
+ -v \
+ --disable-pytest-warnings \
+ --numprocesses=auto \
+ test
+%endif
+
+
+%files -f %{pyproject_files}
+%license LICENSE
+%doc docs/ README.md
+
+%changelog
diff --git a/.config/requirements.in b/.config/requirements.in
index 29c2662..6a9241c 100644
--- a/.config/requirements.in
+++ b/.config/requirements.in
@@ -1,5 +1,5 @@
# https://docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.html
-ansible-core>=2.12
+ansible-core>=2.14
packaging
PyYAML
subprocess-tee>=0.4.1
diff --git a/.git_archival.txt b/.git_archival.txt
index 1f151b2..12d87a5 100644
--- a/.git_archival.txt
+++ b/.git_archival.txt
@@ -1,4 +1,4 @@
-node: 77f4aa656f20def28864421bd8a3c94fceeb61b4
-node-date: 2024-05-08T17:33:47+01:00
-describe-name: v24.5.1
-ref-names: HEAD -> main, tag: v24.5.1a0, tag: v24.5.1
+node: 32ce03c9989698544353a24a5f8b7399c1a0b8a0
+node-date: 2024-05-29T14:33:45+01:00
+describe-name: v24.6.0
+ref-names: HEAD -> main, tag: v24.6.0
diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml
index 26df89b..6a7cd52 100644
--- a/.github/workflows/tox.yml
+++ b/.github/workflows/tox.yml
@@ -12,8 +12,8 @@ on:
workflow_call:
jobs:
- pre:
- name: pre
+ prepare:
+ name: prepare
runs-on: ubuntu-22.04
outputs:
matrix: ${{ steps.generate_matrix.outputs.matrix }}
@@ -29,33 +29,33 @@ jobs:
lint
docs
pkg
- py39-ansible212
- py39-ansible213
py39-ansible214
py39-ansible215
py310-ansible215
+ py310-ansible217
py311-ansible215
py312-ansible216
+ py312-ansible217
py312-devel
smoke
platforms: linux,macos
macos: minmax
build:
name: ${{ matrix.name }}
- environment: test
+
runs-on: ${{ matrix.os || 'ubuntu-22.04' }}
- needs: pre
+ needs: prepare
strategy:
fail-fast: false
- matrix: ${{ fromJson(needs.pre.outputs.matrix) }}
+ matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
env:
FORCE_COLOR: 1
-
+ PYTEST_REQPASS: 108
steps:
- - name: Check out src from Git
- uses: actions/checkout@v4
+ - uses: actions/checkout@v4
with:
fetch-depth: 0 # needed by setuptools-scm
+ submodules: true
- name: Set up Python ${{ matrix.python_version }}
uses: actions/setup-python@v5
@@ -80,37 +80,78 @@ jobs:
- name: Initialize tox envs
run: python3 -m tox --notest --skip-missing-interpreters false -vv -e ${{ matrix.passed_name }}
- - name: Test with tox
+ - name: tox -e ${{ matrix.passed_name }}
run: python3 -m tox -e ${{ matrix.passed_name }}
- name: Archive logs
uses: actions/upload-artifact@v4
with:
name: logs-${{ matrix.name }}.zip
- path: .tox/**/log/
+ path: |
+ .tox/**/log/
+ .tox/**/.coverage*
+ .tox/**/coverage.xml
- - name: Upload coverage data
- if: ${{ startsWith(matrix.passed_name, 'py') }}
- uses: codecov/codecov-action@v4
- with:
- name: ${{ matrix.passed_name }}
- token: ${{ secrets.CODECOV_TOKEN }}
- fail_ci_if_error: true # see https://github.com/codecov/codecov-action/issues/598
- verbose: true # optional (default = false)
+ - name: Report failure if git reports dirty status
+ run: |
+ if [[ -n $(git status -s) ]]; then
+ # shellcheck disable=SC2016
+ echo -n '::error file=git-status::'
+ printf '### Failed as git reported modified and/or untracked files\n```\n%s\n```\n' "$(git status -s)" | tee -a "$GITHUB_STEP_SUMMARY"
+ exit 99
+ fi
+ # https://github.com/actions/toolkit/issues/193
- check: # This job does nothing and is only used for the branch protection
+ check:
if: always()
+ permissions:
+ id-token: write
+ checks: read
needs:
- build
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-latest
+
steps:
+ # checkout needed for codecov action which needs codecov.yml file
+ - uses: actions/checkout@v4
+
+ - name: Set up Python # likely needed for coverage
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - run: pip3 install 'coverage>=7.5.1'
+
- name: Merge logs into a single archive
uses: actions/upload-artifact/merge@v4
with:
name: logs.zip
- pattern: logs*.zip
- delete-merged: true
+ pattern: logs-*.zip
+ # artifacts like py312.zip and py312-macos do have overlapping files
+ separate-directories: true
+
+ - name: Download artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: logs.zip
+ path: .
+
+ - name: Check for expected number of coverage.xml reports
+ run: |
+ JOBS_PRODUCING_COVERAGE=14
+ if [ "$(find . -name coverage.xml | wc -l | bc)" -ne "${JOBS_PRODUCING_COVERAGE}" ]; then
+ echo "::error::Number of coverage.xml files was not the expected one (${JOBS_PRODUCING_COVERAGE}): $(find . -name coverage.xml |xargs echo)"
+ exit 1
+ fi
+
+ - name: Upload coverage data
+ uses: codecov/codecov-action@v4
+ with:
+ name: ${{ matrix.passed_name }}
+ # verbose: true # optional (default = false)
+ fail_ci_if_error: true
+ use_oidc: true # cspell:ignore oidc
- name: Check codecov.io status
if: github.event_name == 'pull_request'
@@ -120,3 +161,8 @@ jobs:
uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
+
+ - name: Delete Merged Artifacts
+ uses: actions/upload-artifact/merge@v4
+ with:
+ delete-merged: true
diff --git a/.gitignore b/.gitignore
index a20c99c..8e14d57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,7 +31,7 @@ MANIFEST
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
-*.spec
+/*.spec
rpm/*.spec
*.rpm
diff --git a/.packit.yaml b/.packit.yaml
index 575e351..cc5d215 100644
--- a/.packit.yaml
+++ b/.packit.yaml
@@ -2,17 +2,16 @@
# https://packit.dev/docs/configuration/
# Test locally running: packit build locally
# spell-checker:ignore packit specfile copr epel
-specfile_path: dist/python-ansible-compat.spec
+specfile_path: dist/python3-ansible-compat.spec
actions:
create-archive:
- # packit.dev service does have these module pre-installed:
+ - sh -c "rm dist/*.tar.gz || true"
- python3 -m build --sdist --outdir dist
- - sh -c "ls dist/ansible-compat-*.tar.gz"
+ - sh -c "ls dist/ansible_compat-*.tar.gz"
get-current-version:
- ./tools/get-version.sh
post-upstream-clone:
- - rm -f dist/*.tar.gz || true
- - ./tools/update-version.sh
+ - ./tools/update-spec.sh
srpm_build_deps:
- python3-build
- python3-setuptools_scm
@@ -20,7 +19,7 @@ srpm_build_deps:
- python3-pytest-mock
jobs:
- job: copr_build
- trigger: commit
+ trigger: pull_request
branch: main
targets:
- fedora-rawhide-x86_64
@@ -40,3 +39,6 @@ jobs:
# trigger: release
# metadata:
# dist-git-branch: master
+notifications:
+ pull_request:
+ successful_build: false
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e8fed89..45043a2 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -26,10 +26,11 @@ repos:
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- - repo: https://github.com/pappasam/toml-sort
- rev: v0.23.1
- hooks:
- - id: toml-sort-fix
+ # https://github.com/pappasam/toml-sort/issues/69
+ # - repo: https://github.com/pappasam/toml-sort
+ # rev: v0.23.1
+ # hooks:
+ # - id: toml-sort-fix
- repo: https://github.com/pre-commit/mirrors-prettier
# keep it before yamllint
rev: "v4.0.0-alpha.8"
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index aa1e537..ca0ca52 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,19 +1,19 @@
{
"recommendations": [
- "Tyriar.sort-lines",
"charliermarsh.ruff",
"esbenp.prettier-vscode",
- "hbenl.vscode-test-explorer",
- "ms-python.isort",
+ "markis.code-coverage",
+ "ms-python.black-formatter",
+ "ms-python.debugpy",
+ "ms-python.mypy-type-checker",
+ "ms-python.pylint",
"ms-python.python",
- "ms-python.vscode-pylance",
- "ms-vscode.live-server",
"redhat.ansible",
"redhat.vscode-yaml",
- "ryanluker.vscode-coverage-gutters",
"shardulm94.trailing-spaces",
+ "streetsidesoftware.code-spell-checker",
"tamasfe.even-better-toml",
- "timonwong.shellcheck",
"znck.grammarly"
- ]
+ ],
+ "unwantedRecommendations": ["ryanluker.vscode-coverage-gutters"]
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 990033d..a4369f3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,13 +1,17 @@
{
+ "[jsonc]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[python]": {
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
- "source.fixAll.ruff": "never",
- "source.organizeImports": "never"
- }
+ "source.organizeImports": "explicit"
+ },
+ "editor.defaultFormatter": "ms-python.black-formatter",
+ "editor.formatOnSave": true
},
"editor.formatOnSave": true,
"evenBetterToml.formatter.alignComments": false,
@@ -24,17 +28,15 @@
"grammarly.files.include": ["**/*.txt", "**/*.md"],
"grammarly.hideUnavailablePremiumAlerts": true,
"grammarly.showExamples": true,
+ "markiscodecoverage.searchCriteria": "coverage.lcov",
+ "mypy-type-checker.importStrategy": "fromEnvironment",
+ "mypy-type-checker.preferDaemon": true,
+ "mypy-type-checker.reportingScope": "workspace",
"python.analysis.exclude": ["build"],
- "python.formatting.provider": "black",
- "python.linting.flake8Args": ["--ignore=E501,W503"],
- "python.linting.flake8Enabled": false,
- "python.linting.mypyCategorySeverity.error": "Warning",
- "python.linting.mypyEnabled": true,
- "python.linting.pylintEnabled": true,
"python.terminal.activateEnvironment": true,
+ "python.testing.pytestArgs": ["tests"],
"python.testing.pytestEnabled": true,
"python.testing.unittestEnabled": false,
- "sortLines.filterBlankLines": true,
"yaml.completion": true,
"yaml.customTags": ["!encrypted/pkcs1-oaep scalar", "!vault scalar"],
"yaml.format.enable": false,
diff --git a/README.md b/README.md
index 32b44b2..7fd4482 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[![codecov.io](https://codecov.io/github/ansible/ansible-compat/coverage.svg?branch=main)](https://codecov.io/github/ansible/ansible-compat?branch=main)
A python package contains functions that facilitate working with various
-versions of Ansible 2.12 and newer.
+versions of Ansible 2.14 and newer.
Documentation is available at
[ansible-compat.readthedocs.io](https://ansible-compat.readthedocs.io/).
diff --git a/pyproject.toml b/pyproject.toml
index 51b6a80..98f12cc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[build-system]
requires = [
"setuptools >= 65.3.0", # required by pyproject+setuptools_scm integration and editable installs
- "setuptools_scm[toml] >= 7.0.5" # required for "no-local-version" scheme
+ "setuptools_scm[toml] >= 7.0.5", # required for "no-local-version" scheme
]
build-backend = "setuptools.build_meta"
@@ -12,9 +12,9 @@ dynamic = ["version", "dependencies", "optional-dependencies"]
name = "ansible-compat"
description = "Ansible compatibility goodies"
readme = "README.md"
-authors = [{"name" = "Sorin Sbarnea", "email" = "ssbarnea@redhat.com"}]
-maintainers = [{"name" = "Sorin Sbarnea", "email" = "ssbarnea@redhat.com"}]
-license = {text = "MIT"}
+authors = [{ "name" = "Sorin Sbarnea", "email" = "ssbarnea@redhat.com" }]
+maintainers = [{ "name" = "Sorin Sbarnea", "email" = "ssbarnea@redhat.com" }]
+license = { text = "MIT" }
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
@@ -33,7 +33,7 @@ classifiers = [
"Topic :: Software Development :: Bug Tracking",
"Topic :: Software Development :: Quality Assurance",
"Topic :: Software Development :: Testing",
- "Topic :: Utilities"
+ "Topic :: Utilities",
]
keywords = ["ansible"]
@@ -45,7 +45,7 @@ changelog = "https://github.com/ansible/ansible-compat/releases"
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
-fail_under = 92
+fail_under = 100
skip_covered = true
show_missing = true
@@ -77,6 +77,11 @@ exclude = "test/local-content"
module = "ansible.*"
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+# generated by setuptools-scm, can be missing during linting
+module = "ansible_compat._version"
+ignore_errors = true
+
[tool.pylint.BASIC]
good-names = [
"f", # filename
@@ -86,23 +91,248 @@ good-names = [
"ns", # namespace
"ex",
"Run",
- "_"
+ "_",
]
[tool.pylint.IMPORTS]
preferred-modules = ["unittest:pytest"]
+[tool.pylint.MASTER]
+# _version.py is generated by setuptools-scm.
+ignore-paths = "^src/ansible_compat/_version.py"
+
[tool.pylint."MESSAGES CONTROL"]
disable = [
+ "unknown-option-value",
+ # https://gist.github.com/cidrblock/ec3412bacfeb34dbc2d334c1d53bef83
+ "C0103", # invalid-name / ruff N815
+ "C0105", # typevar-name-incorrect-variance / ruff PLC0105
+ "C0112", # empty-docstring / ruff D419
+ "C0113", # unneeded-not / ruff SIM208
+ "C0114", # missing-module-docstring / ruff D100
+ "C0115", # missing-class-docstring / ruff D101
+ "C0116", # missing-function-docstring / ruff D103
+ "C0121", # singleton-comparison / ruff PLC0121
+ "C0123", # unidiomatic-typecheck / ruff E721
+ "C0131", # typevar-double-variance / ruff PLC0131
+ "C0132", # typevar-name-mismatch / ruff PLC0132
+ "C0198", # bad-docstring-quotes / ruff Q002
+ "C0199", # docstring-first-line-empty / ruff D210
+ "C0201", # consider-iterating-dictionary / ruff SIM118
+ "C0202", # bad-classmethod-argument / ruff PLC0202
+ "C0205", # single-string-used-for-slots / ruff PLC0205
+ "C0208", # use-sequence-for-iteration / ruff PLC0208
+ "C0301", # line-too-long / ruff E501
+ "C0303", # trailing-whitespace / ruff W291
+ "C0304", # missing-final-newline / ruff W292
+ "C0321", # multiple-statements / ruff PLC0321
+ "C0410", # multiple-imports / ruff E401
+ "C0411", # wrong-import-order / ruff I001
+ "C0412", # ungrouped-imports / ruff I001
+ "C0413", # wrong-import-position / ruff E402
+ "C0414", # useless-import-alias / ruff PLC0414
+ "C0415", # import-outside-toplevel / ruff PLC0415
+ "C0501", # consider-using-any-or-all / ruff PLC0501
+ "C1901", # compare-to-empty-string / ruff PLC1901
+ "C2201", # misplaced-comparison-constant / ruff SIM300
+ "C2401", # non-ascii-name / ruff PLC2401
+ "C2403", # non-ascii-module-import / ruff PLC2403
+ "C2701", # import-private-name / ruff PLC2701
+ "C2801", # unnecessary-dunder-call / ruff PLC2801
+ "C3001", # unnecessary-lambda-assignment / ruff E731
+ "C3002", # unnecessary-direct-lambda-call / ruff PLC3002
+ "E0001", # syntax-error / ruff E999
+ "E0100", # init-is-generator / ruff PLE0100
+ "E0101", # return-in-init / ruff PLE0101
+ "E0102", # function-redefined / ruff F811
+ "E0103", # not-in-loop / ruff PLE0103
+ "E0104", # return-outside-function / ruff F706
+ "E0105", # yield-outside-function / ruff F704
+ "E0107", # nonexistent-operator / ruff B002
+ "E0112", # too-many-star-expressions / ruff F622
+ "E0115", # nonlocal-and-global / ruff PLE0115
+ "E0116", # continue-in-finally / ruff PLE0116
+ "E0117", # nonlocal-without-binding / ruff PLE0117
+ "E0118", # used-prior-global-declaration / ruff PLE0118
+ "E0211", # no-method-argument / ruff N805
+ "E0213", # no-self-argument / ruff N805
+ "E0237", # assigning-non-slot / ruff PLE0237
+ "E0241", # duplicate-bases / ruff PLE0241
+ "E0302", # unexpected-special-method-signature / ruff PLE0302
+ "E0303", # invalid-length-returned / ruff PLE0303
+ "E0304", # invalid-bool-returned / ruff PLE0304
+ "E0305", # invalid-index-returned / ruff PLE0305
+ "E0308", # invalid-bytes-returned / ruff PLE0308
+ "E0309", # invalid-hash-returned / ruff PLE0309
+ "E0402", # relative-beyond-top-level / ruff TID252
+ "E0602", # undefined-variable / ruff F821
+ "E0603", # undefined-all-variable / ruff F822
+ "E0604", # invalid-all-object / ruff PLE0604
+ "E0605", # invalid-all-format / ruff PLE0605
+ "E0643", # potential-index-error / ruff PLE0643
+ "E0704", # misplaced-bare-raise / ruff PLE0704
+ "E0711", # notimplemented-raised / ruff F901
+ "E1132", # repeated-keyword / ruff PLE1132
+ "E1142", # await-outside-async / ruff PLE1142
+ "E1205", # logging-too-many-args / ruff PLE1205
+ "E1206", # logging-too-few-args / ruff PLE1206
+ "E1300", # bad-format-character / ruff PLE1300
+ "E1301", # truncated-format-string / ruff F501
+ "E1302", # mixed-format-string / ruff F506
+ "E1303", # format-needs-mapping / ruff F502
+ "E1304", # missing-format-string-key / ruff F524
+ "E1305", # too-many-format-args / ruff F522
+ "E1306", # too-few-format-args / ruff F524
+ "E1307", # bad-string-format-type / ruff PLE1307
+ "E1310", # bad-str-strip-call / ruff PLE1310
+ "E1519", # singledispatch-method / ruff PLE1519
+ "E1520", # singledispatchmethod-function / ruff PLE5120
+ "E1700", # yield-inside-async-function / ruff PLE1700
+ "E2502", # bidirectional-unicode / ruff PLE2502
+ "E2510", # invalid-character-backspace / ruff PLE2510
+ "E2512", # invalid-character-sub / ruff PLE2512
+ "E2513", # invalid-character-esc / ruff PLE2513
+ "E2514", # invalid-character-nul / ruff PLE2514
+ "E2515", # invalid-character-zero-width-space / ruff PLE2515
+ "E4703", # modified-iterating-set / ruff PLE4703
+ "R0123", # literal-comparison / ruff F632
+ "R0124", # comparison-with-itself / ruff PLR0124
+ "R0133", # comparison-of-constants / ruff PLR0133
+ "R0202", # no-classmethod-decorator / ruff PLR0202
+ "R0203", # no-staticmethod-decorator / ruff PLR0203
+ "R0205", # useless-object-inheritance / ruff UP004
+ "R0206", # property-with-parameters / ruff PLR0206
+ "R0904", # too-many-public-methods / ruff PLR0904
+ "R0911", # too-many-return-statements / ruff PLR0911
+ "R0912", # too-many-branches / ruff PLR0912
+ "R0913", # too-many-arguments / ruff PLR0913
+ "R0914", # too-many-locals / ruff PLR0914
+ "R0915", # too-many-statements / ruff PLR0915
+ "R0916", # too-many-boolean-expressions / ruff PLR0916
+ "R1260", # too-complex / ruff C901
+ "R1701", # consider-merging-isinstance / ruff PLR1701
+ "R1702", # too-many-nested-blocks / ruff PLR1702
+ "R1703", # simplifiable-if-statement / ruff SIM108
+ "R1704", # redefined-argument-from-local / ruff PLR1704
+ "R1705", # no-else-return / ruff RET505
+ "R1706", # consider-using-ternary / ruff PLR1706
+ "R1707", # trailing-comma-tuple / ruff COM818
+ "R1710", # inconsistent-return-statements / ruff PLR1710
+ "R1711", # useless-return / ruff PLR1711
+ "R1714", # consider-using-in / ruff PLR1714
+ "R1715", # consider-using-get / ruff SIM401
+ "R1717", # consider-using-dict-comprehension / ruff C402
+ "R1718", # consider-using-set-comprehension / ruff C401
+ "R1719", # simplifiable-if-expression / ruff PLR1719
+ "R1720", # no-else-raise / ruff RET506
+ "R1721", # unnecessary-comprehension / ruff C416
+ "R1722", # consider-using-sys-exit / ruff PLR1722
+ "R1723", # no-else-break / ruff RET508
+ "R1724", # no-else-continue / ruff RET507
+ "R1725", # super-with-arguments / ruff UP008
+ "R1728", # consider-using-generator / ruff C417
+ "R1729", # use-a-generator / ruff C419
+ "R1730", # consider-using-min-builtin / ruff PLR1730
+ "R1731", # consider-using-max-builtin / ruff PLR1730
+ "R1732", # consider-using-with / ruff SIM115
+ "R1733", # unnecessary-dict-index-lookup / ruff PLR1733
+ "R1734", # use-list-literal / ruff C405
+ "R1735", # use-dict-literal / ruff C406
+ "R1736", # unnecessary-list-index-lookup / ruff PLR1736
+ "R2004", # magic-value-comparison / ruff PLR2004
+ "R2044", # empty-comment / ruff PLR2044
+ "R5501", # else-if-used / ruff PLR5501
+ "R6002", # consider-using-alias / ruff UP006
+ "R6003", # consider-alternative-union-syntax / ruff UP007
+ "R6104", # consider-using-augmented-assign / ruff PLR6104
+ "R6201", # use-set-for-membership / ruff PLR6201
+ "R6301", # no-self-use / ruff PLR6301
+ "W0102", # dangerous-default-value / ruff B006
+ "W0104", # pointless-statement / ruff B018
+ "W0106", # expression-not-assigned / ruff B018
+ "W0107", # unnecessary-pass / ruff PIE790
+ "W0108", # unnecessary-lambda / ruff PLW0108
+ "W0109", # duplicate-key / ruff F601
+ "W0120", # useless-else-on-loop / ruff PLW0120
+ "W0122", # exec-used / ruff S102
+ "W0123", # eval-used / ruff PGH001
+ "W0127", # self-assigning-variable / ruff PLW0127
+ "W0129", # assert-on-string-literal / ruff PLW0129
+ "W0130", # duplicate-value / ruff B033
+ "W0131", # named-expr-without-context / ruff PLW0131
+ "W0133", # pointless-exception-statement / ruff PLW0133
+ "W0150", # lost-exception / ruff B012
+ "W0160", # consider-ternary-expression / ruff SIM108
+ "W0177", # nan-comparison / ruff PLW0117
+ "W0199", # assert-on-tuple / ruff F631
+ "W0211", # bad-staticmethod-argument / ruff PLW0211
+ "W0212", # protected-access / ruff SLF001
+ "W0245", # super-without-brackets / ruff PLW0245
+ "W0301", # unnecessary-semicolon / ruff E703
+ "W0401", # wildcard-import / ruff F403
+ "W0404", # reimported / ruff F811
+ "W0406", # import-self / ruff PLW0406
+ "W0410", # misplaced-future / ruff F404
+ "W0511", # fixme / ruff PLW0511
+ "W0602", # global-variable-not-assigned / ruff PLW0602
+ "W0603", # global-statement / ruff PLW0603
+ "W0604", # global-at-module-level / ruff PLW0604
+ "W0611", # unused-import / ruff F401
+ "W0612", # unused-variable / ruff F841
+ "W0613", # unused-argument / ruff ARG001
+ "W0622", # redefined-builtin / ruff A001
+ "W0640", # cell-var-from-loop / ruff B023
+ "W0702", # bare-except / ruff E722
+ "W0705", # duplicate-except / ruff B014
+ "W0706", # try-except-raise / ruff TRY302
+ "W0707", # raise-missing-from / ruff TRY200
+ "W0711", # binary-op-exception / ruff PLW0711
+ "W0718", # broad-exception-caught / ruff PLW0718
+ "W0719", # broad-exception-raised / ruff TRY002
+ "W1113", # keyword-arg-before-vararg / ruff B026
+ "W1201", # logging-not-lazy / ruff G
+ "W1202", # logging-format-interpolation / ruff G
+ "W1203", # logging-fstring-interpolation / ruff G
+ "W1300", # bad-format-string-key / ruff PLW1300
+ "W1301", # unused-format-string-key / ruff F504
+ "W1302", # bad-format-string / ruff PLW1302
+ "W1303", # missing-format-argument-key / ruff F524
+ "W1304", # unused-format-string-argument / ruff F507
+ "W1305", # format-combined-specification / ruff F525
+ "W1308", # duplicate-string-formatting-argument / ruff PLW1308
+ "W1309", # f-string-without-interpolation / ruff F541
+ "W1310", # format-string-without-interpolation / ruff F541
+ "W1401", # anomalous-backslash-in-string / ruff W605
+ "W1404", # implicit-str-concat / ruff ISC001
+ "W1405", # inconsistent-quotes / ruff Q000
+ "W1406", # redundant-u-string-prefix / ruff UP025
+ "W1501", # bad-open-mode / ruff PLW1501
+ "W1508", # invalid-envvar-default / ruff PLW1508
+ "W1509", # subprocess-popen-preexec-fn / ruff PLW1509
+ "W1510", # subprocess-run-check / ruff PLW1510
+ "W1514", # unspecified-encoding / ruff PLW1514
+ "W1515", # forgotten-debug-statement / ruff T100
+ "W1518", # method-cache-max-size-none / ruff B019
+ "W1641", # eq-without-hash / ruff PLW1641
+ "W2101", # useless-with-lock / ruff PLW2101
+ "W2402", # non-ascii-file-name / ruff N999
+ "W2901", # redefined-loop-name / ruff PLW2901
+ "W3201", # bad-dunder-name / ruff PLW3201
+ "W3301", # nested-min-max / ruff PLW3301
+ "duplicate-code",
+ "fixme",
+ "too-few-public-methods",
+ "unsubscriptable-object",
# On purpose disabled as we rely on black
"line-too-long",
+ "protected-access", # covered by ruff SLF001
# local imports do not work well with pre-commit hook
"import-error",
# already covered by ruff which is faster
"too-many-arguments", # PLR0913
"raise-missing-from",
# Temporary disable duplicate detection we remove old code from prerun
- "duplicate-code"
+ "duplicate-code",
]
[tool.pytest.ini_options]
@@ -111,13 +341,15 @@ filterwarnings = [
"error",
# py312 ansible-core
# https://github.com/ansible/ansible/issues/81906
- "ignore:'importlib.abc.TraversableResources' is deprecated and slated for removal in Python 3.14:DeprecationWarning"
+ "ignore:'importlib.abc.TraversableResources' is deprecated and slated for removal in Python 3.14:DeprecationWarning",
]
testpaths = ["test"]
[tool.ruff]
-select = ["ALL"]
-ignore = [
+extend-include = ["src/ansible_compat/_version.py"]
+target-version = "py39"
+lint.select = ["ALL"]
+lint.ignore = [
# Disabled on purpose:
"ANN101", # Missing type annotation for `self` in method
"D203", # incompatible with D211
@@ -130,27 +362,26 @@ ignore = [
"PLR0912", # Bug https://github.com/charliermarsh/ruff/issues/4244
"PLR0913", # Bug https://github.com/charliermarsh/ruff/issues/4244
"RUF012",
- "PERF203"
+ "PERF203",
]
-target-version = "py39"
-[tool.ruff.flake8-pytest-style]
+[tool.ruff.lint.flake8-pytest-style]
parametrize-values-type = "tuple"
-[tool.ruff.isort]
+[tool.ruff.lint.isort]
known-first-party = ["ansible_compat"]
known-third-party = ["packaging"]
-[tool.ruff.per-file-ignores]
+[tool.ruff.lint.per-file-ignores]
"test/**/*.py" = ["SLF001", "S101", "FBT001"]
-[tool.ruff.pydocstyle]
+[tool.ruff.lint.pydocstyle]
convention = "pep257"
[tool.setuptools.dynamic]
-dependencies = {file = [".config/requirements.in"]}
-optional-dependencies.docs = {file = [".config/requirements-docs.in"]}
-optional-dependencies.test = {file = [".config/requirements-test.in"]}
+dependencies = { file = [".config/requirements.in"] }
+optional-dependencies.docs = { file = [".config/requirements-docs.in"] }
+optional-dependencies.test = { file = [".config/requirements-test.in"] }
[tool.setuptools_scm]
local_scheme = "no-local-version"
@@ -164,5 +395,5 @@ git_describe_command = [
"--tags",
"--long",
"--match",
- "v*.*"
+ "v*.*",
]
diff --git a/src/ansible_compat/config.py b/src/ansible_compat/config.py
index 6bed01b..9435540 100644
--- a/src/ansible_compat/config.py
+++ b/src/ansible_compat/config.py
@@ -8,7 +8,7 @@ import os
import re
import subprocess
from collections import UserDict
-from typing import Literal
+from typing import TYPE_CHECKING, Literal
from packaging.version import Version
@@ -16,6 +16,9 @@ from ansible_compat.constants import ANSIBLE_MIN_VERSION
from ansible_compat.errors import InvalidPrerequisiteError, MissingAnsibleError
from ansible_compat.ports import cache
+if TYPE_CHECKING:
+ from pathlib import Path
+
# do not use lru_cache here, as environment can change between calls
def ansible_collections_path() -> str:
@@ -397,35 +400,49 @@ class AnsibleConfig(UserDict[str, object]): # pylint: disable=too-many-ancestor
self,
config_dump: str | None = None,
data: dict[str, object] | None = None,
+ cache_dir: Path | None = None,
) -> None:
"""Load config dictionary."""
super().__init__()
+ self.cache_dir = cache_dir
if data:
self.data = copy.deepcopy(data)
- return
-
- if not config_dump:
- env = os.environ.copy()
- # Avoid possible ANSI garbage
- env["ANSIBLE_FORCE_COLOR"] = "0"
- config_dump = subprocess.check_output(
- ["ansible-config", "dump"], # noqa: S603
- universal_newlines=True,
- env=env,
- )
+ else:
+ if not config_dump:
+ env = os.environ.copy()
+ # Avoid possible ANSI garbage
+ env["ANSIBLE_FORCE_COLOR"] = "0"
+ config_dump = subprocess.check_output(
+ ["ansible-config", "dump"], # noqa: S603
+ universal_newlines=True,
+ env=env,
+ )
- for match in re.finditer(
- r"^(?P<key>[A-Za-z0-9_]+).* = (?P<value>.*)$",
- config_dump,
- re.MULTILINE,
- ):
- key = match.groupdict()["key"]
- value = match.groupdict()["value"]
- try:
- self[key] = ast.literal_eval(value)
- except (NameError, SyntaxError, ValueError):
- self[key] = value
+ for match in re.finditer(
+ r"^(?P<key>[A-Za-z0-9_]+).* = (?P<value>.*)$",
+ config_dump,
+ re.MULTILINE,
+ ):
+ key = match.groupdict()["key"]
+ value = match.groupdict()["value"]
+ try:
+ self[key] = ast.literal_eval(value)
+ except (NameError, SyntaxError, ValueError):
+ self[key] = value
+ # inject isolation collections paths into the config
+ if self.cache_dir:
+ cpaths = self.data["COLLECTIONS_PATHS"]
+ if cpaths and isinstance(cpaths, list):
+ cpaths.insert(
+ 0,
+ f"{self.cache_dir}/collections",
+ )
+ else: # pragma: no cover
+ msg = f"Unexpected data type for COLLECTIONS_PATHS: {cpaths}"
+ raise RuntimeError(msg)
+ if data:
+ return
def __getattribute__(self, attr_name: str) -> object:
"""Allow access of config options as attributes."""
diff --git a/src/ansible_compat/constants.py b/src/ansible_compat/constants.py
index f3d7866..f5335ab 100644
--- a/src/ansible_compat/constants.py
+++ b/src/ansible_compat/constants.py
@@ -14,7 +14,7 @@ REQUIREMENT_LOCATIONS = [
]
# Minimal version of Ansible we support for runtime
-ANSIBLE_MIN_VERSION = "2.12"
+ANSIBLE_MIN_VERSION = "2.14"
# Based on https://docs.ansible.com/ansible/latest/reference_appendices/config.html
ANSIBLE_DEFAULT_ROLES_PATH = (
diff --git a/src/ansible_compat/runtime.py b/src/ansible_compat/runtime.py
index fbeaa98..9ed1853 100644
--- a/src/ansible_compat/runtime.py
+++ b/src/ansible_compat/runtime.py
@@ -1,5 +1,7 @@
"""Ansible runtime environment manager."""
+# pylint: disable=too-many-lines
+
from __future__ import annotations
import contextlib
@@ -23,7 +25,6 @@ from packaging.version import Version
from ansible_compat.config import (
AnsibleConfig,
ansible_collections_path,
- ansible_version,
parse_ansible_version,
)
from ansible_compat.constants import (
@@ -128,9 +129,6 @@ class Plugins: # pylint: disable=too-many-instance-attributes
try:
result = super().__getattribute__(attr)
except AttributeError as exc:
- if ansible_version() < Version("2.14") and attr in {"filter", "test"}:
- msg = "Ansible version below 2.14 does not support retrieving filter and test plugins."
- raise RuntimeError(msg) from exc
proc = self.runtime.run(
["ansible-doc", "--json", "-l", "-t", attr],
)
@@ -211,7 +209,7 @@ class Runtime:
if isolated:
self.cache_dir = get_cache_dir(self.project_dir)
- self.config = AnsibleConfig()
+ self.config = AnsibleConfig(cache_dir=self.cache_dir)
# Add the sys.path to the collection paths if not isolated
self._add_sys_path_to_collection_paths()
@@ -231,7 +229,7 @@ class Runtime:
msg: str,
*,
formatted: bool = False, # noqa: ARG001
- ) -> None:
+ ) -> None: # pragma: no cover
"""Override ansible.utils.display.Display.warning to avoid printing warnings."""
warnings.warn(
message=msg,
@@ -275,34 +273,50 @@ class Runtime:
self.collections = OrderedDict()
no_collections_msg = "None of the provided paths were usable"
- proc = self.run(["ansible-galaxy", "collection", "list", "--format=json"])
+ # do not use --path because it does not allow multiple values
+ proc = self.run(
+ [
+ "ansible-galaxy",
+ "collection",
+ "list",
+ "--format=json",
+ ],
+ )
if proc.returncode == RC_ANSIBLE_OPTIONS_ERROR and (
no_collections_msg in proc.stdout or no_collections_msg in proc.stderr
- ):
+ ): # pragma: no cover
_logger.debug("Ansible reported no installed collections at all.")
return
if proc.returncode != 0:
_logger.error(proc)
msg = f"Unable to list collections: {proc}"
raise RuntimeError(msg)
- data = json.loads(proc.stdout)
+ try:
+ data = json.loads(proc.stdout)
+ except json.decoder.JSONDecodeError as exc:
+ msg = f"Unable to parse galaxy output as JSON: {proc.stdout}"
+ raise RuntimeError(msg) from exc
if not isinstance(data, dict):
msg = f"Unexpected collection data, {data}"
raise TypeError(msg)
for path in data:
+ if not isinstance(data[path], dict):
+ msg = f"Unexpected collection data, {data[path]}"
+ raise TypeError(msg)
for collection, collection_info in data[path].items():
- if not isinstance(collection, str):
- msg = f"Unexpected collection data, {collection}"
- raise TypeError(msg)
if not isinstance(collection_info, dict):
msg = f"Unexpected collection data, {collection_info}"
raise TypeError(msg)
- self.collections[collection] = Collection(
- name=collection,
- version=collection_info["version"],
- path=path,
- )
+ if collection in self.collections:
+ msg = f"Multiple versions of '{collection}' were found installed, only the first one will be used, {self.collections[collection].version} ({self.collections[collection].path})."
+ logging.warning(msg)
+ else:
+ self.collections[collection] = Collection(
+ name=collection,
+ version=collection_info["version"],
+ path=path,
+ )
def _ensure_module_available(self) -> None:
"""Assure that Ansible Python module is installed and matching CLI version."""
@@ -378,6 +392,8 @@ class Runtime:
# https://github.com/ansible/ansible-lint/issues/3522
env["ANSIBLE_VERBOSE_TO_STDERR"] = "True"
+ env["ANSIBLE_COLLECTIONS_PATH"] = ":".join(self.config.collections_paths)
+
for _ in range(self.max_retries + 1 if retry else 1):
result = run_func(
args,
@@ -506,7 +522,7 @@ class Runtime:
env={**self.environ, ansible_collections_path(): ":".join(cpaths)},
)
if process.returncode != 0:
- msg = f"Command returned {process.returncode} code:\n{process.stdout}\n{process.stderr}"
+ msg = f"Command {' '.join(cmd)}, returned {process.returncode} code:\n{process.stdout}\n{process.stderr}"
_logger.error(msg)
raise InvalidPrerequisiteError(msg)
@@ -594,19 +610,10 @@ class Runtime:
)
else:
cmd.extend(["-r", str(requirement)])
- cpaths = self.config.collections_paths
- if self.cache_dir:
- # we cannot use '-p' because it breaks galaxy ability to ignore already installed collections, so
- # we hack ansible_collections_path instead and inject our own path there.
- dest_path = f"{self.cache_dir}/collections"
- if dest_path not in cpaths:
- # pylint: disable=no-member
- cpaths.insert(0, dest_path)
_logger.info("Running %s", " ".join(cmd))
result = self.run(
cmd,
retry=retry,
- env={**os.environ, "ANSIBLE_COLLECTIONS_PATH": ":".join(cpaths)},
)
_logger.debug(result.stdout)
if result.returncode != 0:
@@ -743,12 +750,6 @@ class Runtime:
msg,
)
- if self.cache_dir:
- # if we have a cache dir, we want to be use that would be preferred
- # destination when installing a missing collection
- # https://github.com/PyCQA/pylint/issues/4667
- paths.insert(0, f"{self.cache_dir}/collections") # pylint: disable=E1101
-
for path in paths:
collpath = Path(path) / "ansible_collections" / ns / coll
if collpath.exists():
@@ -772,18 +773,16 @@ class Runtime:
_logger.fatal(msg)
raise InvalidPrerequisiteError(msg)
return found_version, collpath.resolve()
- break
- else:
- if install:
- self.install_collection(f"{name}:>={version}" if version else name)
- return self.require_collection(
- name=name,
- version=version,
- install=False,
- )
- msg = f"Collection '{name}' not found in '{paths}'"
- _logger.fatal(msg)
- raise InvalidPrerequisiteError(msg)
+ if install:
+ self.install_collection(f"{name}:>={version}" if version else name)
+ return self.require_collection(
+ name=name,
+ version=version,
+ install=False,
+ )
+ msg = f"Collection '{name}' not found in '{paths}'"
+ _logger.fatal(msg)
+ raise InvalidPrerequisiteError(msg)
def _prepare_ansible_paths(self) -> None:
"""Configure Ansible environment variables."""
diff --git a/test/roles/acme.missing_deps/requirements.yml b/test/roles/acme.missing_deps/requirements.yml
index 53c5937..58d68f1 100644
--- a/test/roles/acme.missing_deps/requirements.yml
+++ b/test/roles/acme.missing_deps/requirements.yml
@@ -1,2 +1,4 @@
collections:
- foo.bar # collection that does not exist, so we can test offline mode
+roles:
+ - this_role_does_not_exist # and also role that does not exist
diff --git a/test/test_config.py b/test/test_config.py
index ebdde00..bedb2d4 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -7,7 +7,12 @@ import pytest
from _pytest.monkeypatch import MonkeyPatch
from packaging.version import Version
-from ansible_compat.config import AnsibleConfig, ansible_version, parse_ansible_version
+from ansible_compat.config import (
+ AnsibleConfig,
+ ansible_collections_path,
+ ansible_version,
+ parse_ansible_version,
+)
from ansible_compat.errors import InvalidPrerequisiteError, MissingAnsibleError
@@ -85,3 +90,17 @@ def test_ansible_version() -> None:
def test_ansible_version_arg() -> None:
"""Validate ansible_version behavior."""
assert ansible_version("2.0") >= Version("1.0")
+
+
+@pytest.mark.parametrize(
+ "var",
+ ("", "ANSIBLE_COLLECTIONS_PATH", "ANSIBLE_COLLECTIONS_PATHS"),
+ ids=["blank", "singular", "plural"],
+)
+def test_ansible_collections_path_env(var: str, monkeypatch: MonkeyPatch) -> None:
+ """Test that ansible_collections_path returns the appropriate env var."""
+ # Set the variable
+ if var:
+ monkeypatch.setenv(var, "")
+
+ assert ansible_collections_path() == (var or "ANSIBLE_COLLECTIONS_PATH")
diff --git a/test/test_runtime.py b/test/test_runtime.py
index 0823f60..ebf99c9 100644
--- a/test/test_runtime.py
+++ b/test/test_runtime.py
@@ -1,6 +1,6 @@
"""Tests for Runtime class."""
-# pylint: disable=protected-access
+# pylint: disable=protected-access,too-many-lines
from __future__ import annotations
import logging
@@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any
import pytest
from packaging.version import Version
-from ansible_compat.config import ansible_version
from ansible_compat.constants import INVALID_PREREQUISITES_RC
from ansible_compat.errors import (
AnsibleCommandError,
@@ -182,10 +181,13 @@ def test_runtime_install_role(
runtime.cache_dir = tmp_dir
-def test_prepare_environment_with_collections(tmp_path: pathlib.Path) -> None:
+def test_prepare_environment_with_collections(runtime_tmp: Runtime) -> None:
"""Check that collections are correctly installed."""
- runtime = Runtime(isolated=True, project_dir=tmp_path)
- runtime.prepare_environment(required_collections={"community.molecule": "0.1.0"})
+ runtime_tmp.prepare_environment(
+ required_collections={"community.molecule": "0.1.0"},
+ install_local=True,
+ )
+ assert "community.molecule" in runtime_tmp.collections
def test_runtime_install_requirements_missing_file() -> None:
@@ -438,19 +440,25 @@ def test_require_collection_invalid_collections_path(runtime: Runtime) -> None:
runtime.require_collection("community.molecule")
-def test_require_collection_preexisting_broken(tmp_path: pathlib.Path) -> None:
+def test_require_collection_preexisting_broken(runtime_tmp: Runtime) -> None:
"""Check that require_collection raise with broken pre-existing collection."""
- runtime = Runtime(isolated=True, project_dir=tmp_path)
- dest_path: str = runtime.config.collections_paths[0]
+ dest_path: str = runtime_tmp.config.collections_paths[0]
dest = pathlib.Path(dest_path) / "ansible_collections" / "foo" / "bar"
dest.mkdir(parents=True, exist_ok=True)
with pytest.raises(InvalidPrerequisiteError, match="missing MANIFEST.json"):
- runtime.require_collection("foo.bar")
+ runtime_tmp.require_collection("foo.bar")
-def test_require_collection(runtime_tmp: Runtime) -> None:
- """Check that require collection successful install case."""
- runtime_tmp.require_collection("community.molecule", "0.1.0")
+def test_require_collection_install(runtime_tmp: Runtime) -> None:
+ """Check that require collection successful install case, including upgrade path."""
+ runtime_tmp.install_collection("ansible.posix:==1.5.2")
+ runtime_tmp.load_collections()
+ collection = runtime_tmp.collections["ansible.posix"]
+ assert collection.version == "1.5.2"
+ runtime_tmp.require_collection(name="ansible.posix", version="1.5.4", install=True)
+ runtime_tmp.load_collections()
+ collection = runtime_tmp.collections["ansible.posix"]
+ assert collection.version == "1.5.4"
@pytest.mark.parametrize(
@@ -532,11 +540,14 @@ def test_install_galaxy_role_unlink(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test ability to unlink incorrect symlinked roles."""
- runtime_tmp = Runtime(verbosity=1)
+ runtime_tmp = Runtime(verbosity=1, isolated=True)
runtime_tmp.prepare_environment()
+ assert runtime_tmp.cache_dir is not None
pathlib.Path(f"{runtime_tmp.cache_dir}/roles").mkdir(parents=True, exist_ok=True)
- pathlib.Path(f"{runtime_tmp.cache_dir}/roles/acme.get_rich").symlink_to("/dev/null")
- pathlib.Path(f"{runtime_tmp.project_dir}/meta").mkdir()
+ roledir = pathlib.Path(f"{runtime_tmp.cache_dir}/roles/acme.get_rich")
+ if not roledir.exists():
+ roledir.symlink_to("/dev/null")
+ pathlib.Path(f"{runtime_tmp.project_dir}/meta").mkdir(exist_ok=True)
pathlib.Path(f"{runtime_tmp.project_dir}/meta/main.yml").write_text(
"""galaxy_info:
role_name: get_rich
@@ -546,6 +557,7 @@ def test_install_galaxy_role_unlink(
)
runtime_tmp._install_galaxy_role(runtime_tmp.project_dir)
assert "symlink to current repository" in caplog.text
+ pathlib.Path(f"{runtime_tmp.project_dir}/meta/main.yml").unlink()
def test_install_galaxy_role_bad_namespace(runtime_tmp: Runtime) -> None:
@@ -563,6 +575,18 @@ def test_install_galaxy_role_bad_namespace(runtime_tmp: Runtime) -> None:
runtime_tmp._install_galaxy_role(runtime_tmp.project_dir, role_name_check=1)
+def test_install_galaxy_role_no_meta(runtime_tmp: Runtime) -> None:
+ """Check install role with missing meta/main.yml."""
+ # This should fail because meta/main.yml is missing
+ with pytest.raises(
+ FileNotFoundError,
+ match=f"No such file or directory: '{runtime_tmp.project_dir.absolute()}/meta/main.yaml'",
+ ):
+ runtime_tmp._install_galaxy_role(runtime_tmp.project_dir)
+ # But ignore_errors will return without doing anything
+ runtime_tmp._install_galaxy_role(runtime_tmp.project_dir, ignore_errors=True)
+
+
@pytest.mark.parametrize(
"galaxy_info",
(
@@ -737,11 +761,83 @@ def test_install_collection_from_disk_fail() -> None:
)
-def test_prepare_environment_offline_role() -> None:
+def test_load_collections_failure(mocker: MockerFixture) -> None:
+ """Tests for ansible-galaxy erroring."""
+ mocker.patch(
+ "ansible_compat.runtime.Runtime.run",
+ return_value=CompletedProcess(
+ ["x"],
+ returncode=1,
+ stdout="There was an error",
+ stderr="This is the error",
+ ),
+ autospec=True,
+ )
+ runtime = Runtime()
+ with pytest.raises(RuntimeError, match="Unable to list collections: "):
+ runtime.load_collections()
+
+
+@pytest.mark.parametrize(
+ "value",
+ ("[]", '{"path": "bad data"}', '{"path": {"ansible.posix": 123}}'),
+ ids=["list", "malformed_collection", "bad_collection_data"],
+)
+def test_load_collections_garbage(value: str, mocker: MockerFixture) -> None:
+ """Tests for ansible-galaxy returning bad data."""
+ mocker.patch(
+ "ansible_compat.runtime.Runtime.run",
+ return_value=CompletedProcess(
+ ["x"],
+ returncode=0,
+ stdout=value,
+ stderr="",
+ ),
+ autospec=True,
+ )
+ runtime = Runtime()
+ with pytest.raises(TypeError, match="Unexpected collection data, "):
+ runtime.load_collections()
+
+
+@pytest.mark.parametrize(
+ "value",
+ ("", '{"path": {123: 456}}'),
+ ids=["nothing", "bad_collection_name"],
+)
+def test_load_collections_invalid_json(value: str, mocker: MockerFixture) -> None:
+ """Tests for ansible-galaxy returning bad data."""
+ mocker.patch(
+ "ansible_compat.runtime.Runtime.run",
+ return_value=CompletedProcess(
+ ["x"],
+ returncode=0,
+ stdout=value,
+ stderr="",
+ ),
+ autospec=True,
+ )
+ runtime = Runtime()
+ with pytest.raises(
+ RuntimeError,
+ match=f"Unable to parse galaxy output as JSON: {value}",
+ ):
+ runtime.load_collections()
+
+
+def test_prepare_environment_offline_role(caplog: pytest.LogCaptureFixture) -> None:
"""Ensure that we can make use of offline roles."""
with cwd(Path("test/roles/acme.missing_deps")):
runtime = Runtime(isolated=True)
runtime.prepare_environment(install_local=True, offline=True)
+ assert (
+ "Skipped installing old role dependencies due to running in offline mode."
+ in caplog.text
+ )
+ assert (
+ "Skipped installing collection dependencies due to running in offline mode."
+ in caplog.text
+ )
def test_runtime_run(runtime: Runtime) -> None:
@@ -785,35 +881,18 @@ def test_runtime_plugins(runtime: Runtime) -> None:
assert isinstance(runtime.plugins.role, dict)
assert "become" in runtime.plugins.keyword
- if ansible_version() < Version("2.14.0"):
- assert "sudo" in runtime.plugins.become
- assert "memory" in runtime.plugins.cache
- assert "default" in runtime.plugins.callback
- assert "local" in runtime.plugins.connection
- assert "ini" in runtime.plugins.inventory
- assert "env" in runtime.plugins.lookup
- assert "sh" in runtime.plugins.shell
- assert "host_group_vars" in runtime.plugins.vars
- assert "file" in runtime.plugins.module
- assert "free" in runtime.plugins.strategy
- # ansible-doc below 2.14 does not support listing 'test' and 'filter' types:
- with pytest.raises(RuntimeError):
- assert "is_abs" in runtime.plugins.test
- with pytest.raises(RuntimeError):
- assert "bool" in runtime.plugins.filter
- else:
- assert "ansible.builtin.sudo" in runtime.plugins.become
- assert "ansible.builtin.memory" in runtime.plugins.cache
- assert "ansible.builtin.default" in runtime.plugins.callback
- assert "ansible.builtin.local" in runtime.plugins.connection
- assert "ansible.builtin.ini" in runtime.plugins.inventory
- assert "ansible.builtin.env" in runtime.plugins.lookup
- assert "ansible.builtin.sh" in runtime.plugins.shell
- assert "ansible.builtin.host_group_vars" in runtime.plugins.vars
- assert "ansible.builtin.file" in runtime.plugins.module
- assert "ansible.builtin.free" in runtime.plugins.strategy
- assert "ansible.builtin.is_abs" in runtime.plugins.test
- assert "ansible.builtin.bool" in runtime.plugins.filter
+ assert "ansible.builtin.sudo" in runtime.plugins.become
+ assert "ansible.builtin.memory" in runtime.plugins.cache
+ assert "ansible.builtin.default" in runtime.plugins.callback
+ assert "ansible.builtin.local" in runtime.plugins.connection
+ assert "ansible.builtin.ini" in runtime.plugins.inventory
+ assert "ansible.builtin.env" in runtime.plugins.lookup
+ assert "ansible.builtin.sh" in runtime.plugins.shell
+ assert "ansible.builtin.host_group_vars" in runtime.plugins.vars
+ assert "ansible.builtin.file" in runtime.plugins.module
+ assert "ansible.builtin.free" in runtime.plugins.strategy
+ assert "ansible.builtin.is_abs" in runtime.plugins.test
+ assert "ansible.builtin.bool" in runtime.plugins.filter
@pytest.mark.parametrize(
@@ -866,11 +945,20 @@ def test_is_url(name: str, result: bool) -> None:
assert is_url(name) == result
-def test_prepare_environment_repair_broken_symlink(
+@pytest.mark.parametrize(
+ ("dest", "message"),
+ (
+ ("/invalid/destination", "Collection is symlinked, but not pointing to"),
+ (Path.cwd(), "Found symlinked collection, skipping its installation."),
+ ),
+ ids=["broken", "valid"],
+)
+def test_prepare_environment_symlink(
+ dest: str | Path,
+ message: str,
caplog: pytest.LogCaptureFixture,
) -> None:
- """Ensure we can deal with broken symlinks in collections."""
- caplog.set_level(logging.INFO)
+ """Ensure avalid symlinks to collections are properly detected."""
project_dir = Path(__file__).parent / "collections" / "acme.minimal"
runtime = Runtime(isolated=True, project_dir=project_dir)
assert runtime.cache_dir
@@ -879,12 +967,9 @@ def test_prepare_environment_repair_broken_symlink(
goodies = acme / "minimal"
rmtree(goodies, ignore_errors=True)
goodies.unlink(missing_ok=True)
- goodies.symlink_to("/invalid/destination")
+ goodies.symlink_to(dest)
runtime.prepare_environment(install_local=True)
- assert any(
- msg.startswith("Collection is symlinked, but not pointing to")
- for msg in caplog.messages
- )
+ assert message in caplog.text
def test_get_galaxy_role_name_invalid() -> None:
diff --git a/test/test_schema.py b/test/test_schema.py
index 10c1a9a..91616a9 100644
--- a/test/test_schema.py
+++ b/test/test_schema.py
@@ -72,3 +72,16 @@ def test_schema(index: int) -> None:
def test_json_path() -> None:
"""Test json_path function."""
assert json_path(["a", 1, "b"]) == "$.a[1].b"
+
+
+def test_validate_invalid_schema() -> None:
+ """Test validate function error handling."""
+ schema = "[]"
+ data = json_from_asset("assets/validate0_data.json")
+ errors = validate(schema, data)
+
+ assert len(errors) == 1
+ assert (
+ errors[0].to_friendly()
+ == "In 'schema sanity check': Invalid schema, must be a mapping."
+ )
diff --git a/test/test_types.py b/test/test_types.py
new file mode 100644
index 0000000..6702b48
--- /dev/null
+++ b/test/test_types.py
@@ -0,0 +1,9 @@
+"""Tests for types module."""
+
+import ansible_compat.types
+
+
+def test_types() -> None:
+ """Tests that JSON types are exported."""
+ assert ansible_compat.types.JSON
+ assert ansible_compat.types.JSON_ro
diff --git a/test/test_version.py b/test/test_version.py
new file mode 100644
index 0000000..b5d26ab
--- /dev/null
+++ b/test/test_version.py
@@ -0,0 +1,13 @@
+"""Tests for _version module."""
+
+
+def test_version_module() -> None:
+ """Tests that _version exports are present."""
+ # import kept here to allow mypy/pylint to run when module is not installed
+ # and the generated _version.py is missing.
+ # pylint: disable=no-name-in-module,no-member
+ import ansible_compat._version # type: ignore[import-not-found,unused-ignore]
+
+ assert ansible_compat._version.__version__
+ assert ansible_compat._version.__version_tuple__
+ assert ansible_compat._version.version
diff --git a/tools/get-version.sh b/tools/get-version.sh
index 67033f8..a8b9a53 100755
--- a/tools/get-version.sh
+++ b/tools/get-version.sh
@@ -1,7 +1,14 @@
#!/bin/bash
set -e
{
- python3 -c "import setuptools_scm" || python3 -m pip install --user setuptools-scm
+ python3 -c "import setuptools_scm" >/dev/null || {
+ if [[ "$VIRTUAL_ENV" != "" ]]; then
+ PIPARGS=""
+ else
+ PIPARGS="--user"
+ fi
+ python3 -m pip install $PIPARGS setuptools-scm
+ }
} 1>&2 # redirect stdout to stderr to avoid polluting the output
python3 -m setuptools_scm | \
sed 's/Guessed Version\([^+]\+\).*/\1/'
diff --git a/tools/update-spec.sh b/tools/update-spec.sh
new file mode 100755
index 0000000..2e8ceee
--- /dev/null
+++ b/tools/update-spec.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+DIR=$(dirname "$0")
+VERSION=$(./tools/get-version.sh)
+mkdir -p "${DIR}/../dist"
+sed -e "s/VERSION_PLACEHOLDER/${VERSION}/" \
+ "${DIR}/../.config/python3-ansible-compat.spec" \
+ > "${DIR}/../dist/python3-ansible-compat.spec"
+
+export LC_ALL=en_US.UTF-8
+CHANGELOG=$(git log -n 20 --pretty="* %ad %an %ae \n- %s\n" --date=format:"%a %b %d %Y")
+NUM=$(grep -nr "%changelog" ${DIR}/../dist/python3-ansible-compat.spec|awk -F':' '{print $1}')
+let NUM_START=$NUM+1
+NUM_END=$(awk '{print NR}' ${DIR}/../dist/pytho3n-ansible-compat.spec|tail -n1)
+sed -i "${NUM_START},${NUM_END}d" ${DIR}/../dist/python3-ansible-compat.spec
+echo -e "$CHANGELOG" >> ${DIR}/../dist/python3-ansible-compat.spec
diff --git a/tools/update-version.sh b/tools/update-version.sh
deleted file mode 100755
index a227023..0000000
--- a/tools/update-version.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-DIR=$(dirname "$0")
-VERSION=$(./tools/get-version.sh)
-mkdir -p "${DIR}/../dist"
-sed -e "s/VERSION_PLACEHOLDER/${VERSION}/" \
- "${DIR}/../dist/python-ansible-compat.spec.in" \
- > "${DIR}/../dist/python-ansible-compat.spec"
diff --git a/tox.ini b/tox.ini
index 6f6c82b..06f505d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,21 +1,15 @@
[tox]
-minversion = 4.0.0
+minversion = 4.6.3
envlist =
lint
pkg
docs
py
py-devel
- py39-ansible212
- py39-ansible213
py39-ansible214
py39-ansible215
- py310-ansible212
- py310-ansible213
py310-ansible214
py310-ansible215
- py311-ansible212
- py311-ansible213
py311-ansible214
py311-ansible215
py312-ansible216
@@ -30,18 +24,16 @@ requires =
description =
Run the tests
devel: ansible devel branch
- ansible212: ansible-core 2.12
- ansible213: ansible-core 2.13
ansible214: ansible-core 2.14
ansible215: ansible-core 2.15
ansible216: ansible-core 2.16
+ ansible217: ansible-core 2.17
deps =
- ansible212: ansible-core>=2.12,<2.13
- ansible213: ansible-core>=2.13,<2.14
ansible214: ansible-core>=2.14,<2.15
ansible215: ansible-core>=2.15,<2.16
ansible216: ansible-core>=2.16,<2.17
+ ansible217: ansible-core>=2.17,<2.18
devel: ansible-core @ git+https://github.com/ansible/ansible.git@c5d18c39d81e2b3b10856b2fb76747230e4fac4a # GPLv3+
# avoid installing ansible-core on -devel envs:
@@ -49,45 +41,46 @@ deps =
extras =
test
+
+commands_pre =
+ # safety measure to assure we do not accidentally run tests with broken dependencies
+ {envpython} -m pip check
+ # cleaning needed to prevent errors between runs
+ sh -c "rm -f {envdir}/.coverage.* 2>/dev/null || true"
commands =
sh -c "ansible --version | head -n 1"
# We add coverage options but not making them mandatory as we do not want to force
# pytest users to run coverage when they just want to run a single test with `pytest -k test`
coverage run -m pytest {posargs:}
- sh -c "coverage combine -a -q --data-file=.coverage {toxworkdir}/.coverage.*"
# needed for upload to codecov.io
- -sh -c "COVERAGE_FILE= coverage xml --ignore-errors -q --fail-under=0"
- # needed for vscode integration due to https://github.com/ryanluker/vscode-coverage-gutters/issues/403
- -sh -c "COVERAGE_FILE= coverage lcov --ignore-errors -q --fail-under=0"
- sh -c "COVERAGE_FILE= coverage report"
+ {py,py39,py310,py311,py312,py313}: sh -c "coverage combine -q --data-file={envdir}/.coverage {envdir}/.coverage.* && coverage xml --data-file={envdir}/.coverage -o {envdir}/coverage.xml --ignore-errors --fail-under=0 && COVERAGE_FILE={envdir}/.coverage coverage lcov --fail-under=0 --ignore-errors -q && COVERAGE_FILE={envdir}/.coverage coverage report --fail-under=0 --ignore-errors"
+ # lcov needed for vscode integration due to https://github.com/ryanluker/vscode-coverage-gutters/issues/403
# We fail if files are modified at the end
git diff --exit-code
-commands_pre =
- # safety measure to assure we do not accidentally run tests with broken dependencies
- {envpython} -m pip check
- # cleaning needed to prevent errors between runs
- sh -c "rm -f .coverage {toxworkdir}/.coverage.* 2>/dev/null || true"
passenv =
CURL_CA_BUNDLE # https proxies, https://github.com/tox-dev/tox/issues/1437
FORCE_COLOR
HOME
NO_COLOR
PYTEST_* # allows developer to define their own preferences
+ PYTEST_REQPASS # needed for CI
+ PYTHON* # PYTHONPYCACHEPREFIX, PYTHONIOENCODING, PYTHONBREAKPOINT,...
PY_COLORS
+ RTD_TOKEN
REQUESTS_CA_BUNDLE # https proxies
+ SETUPTOOLS_SCM_DEBUG
SSL_CERT_FILE # https proxies
+ SSH_AUTH_SOCK # may be needed by git
LANG
- LC_ALL
- LC_CTYPE
+ LC_*
setenv =
ANSIBLE_DEVEL_WARNING='false'
- COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}/.coverage.{envname}}
+ COVERAGE_FILE = {env:COVERAGE_FILE:{envdir}/.coverage.{envname}}
COVERAGE_PROCESS_START={toxinidir}/pyproject.toml
PIP_DISABLE_PIP_VERSION_CHECK = 1
PIP_CONSTRAINT = {toxinidir}/.config/constraints.txt
PRE_COMMIT_COLOR = always
- PYTEST_REQPASS = 94
FORCE_COLOR = 1
allowlist_externals =
ansible
@@ -152,17 +145,17 @@ commands =
pip uninstall -y ansible-compat
[testenv:py]
-description = Run the tests with {basepython} ansible-core 2.12+
+description = Run the tests with {basepython} ansible-core 2.14+
deps =
{[testenv]deps}
- ansible-core>=2.12
+ ansible-core>=2.14
[testenv:rpm]
description = Use packit to build RPM (requires RPM based Linux distro)
deps =
packitos
commands =
- packit build in-mock
+ sh -c "packit build in-mock --root=fedora-40-$(arch)"
[testenv:docs]
description = Build docs