diff options
-rw-r--r-- | .config/python3-ansible-compat.spec | 66 | ||||
-rw-r--r-- | .config/requirements.in | 2 | ||||
-rw-r--r-- | .git_archival.txt | 8 | ||||
-rw-r--r-- | .github/workflows/tox.yml | 94 | ||||
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | .packit.yaml | 14 | ||||
-rw-r--r-- | .pre-commit-config.yaml | 9 | ||||
-rw-r--r-- | .vscode/extensions.json | 16 | ||||
-rw-r--r-- | .vscode/settings.json | 22 | ||||
-rw-r--r-- | README.md | 2 | ||||
-rw-r--r-- | pyproject.toml | 273 | ||||
-rw-r--r-- | src/ansible_compat/config.py | 63 | ||||
-rw-r--r-- | src/ansible_compat/constants.py | 2 | ||||
-rw-r--r-- | src/ansible_compat/runtime.py | 89 | ||||
-rw-r--r-- | test/roles/acme.missing_deps/requirements.yml | 2 | ||||
-rw-r--r-- | test/test_config.py | 21 | ||||
-rw-r--r-- | test/test_runtime.py | 191 | ||||
-rw-r--r-- | test/test_schema.py | 13 | ||||
-rw-r--r-- | test/test_types.py | 9 | ||||
-rw-r--r-- | test/test_version.py | 13 | ||||
-rwxr-xr-x | tools/get-version.sh | 9 | ||||
-rwxr-xr-x | tools/update-spec.sh | 15 | ||||
-rwxr-xr-x | tools/update-version.sh | 7 | ||||
-rw-r--r-- | tox.ini | 49 |
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 @@ -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, @@ -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" @@ -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 |