diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b290371971..45892f49ae 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ - [ ] Have you followed the [guidelines for contributing](https://github.com/snapcore/snapcraft/blob/master/CONTRIBUTING.md)? - [ ] Have you signed the [CLA](http://www.ubuntu.com/legal/contributors/)? -- [ ] Have you successfully run `./runtests.sh static`? -- [ ] Have you successfully run `./runtests.sh tests/unit`? +- [ ] Have you successfully run `make lint`? +- [ ] Have you successfully run `pytest tests/unit`? ----- diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f9a673d18a..48b73fa8e2 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -12,15 +12,16 @@ jobs: run: | # Secrets cannot be used in conditionals, so this is our dance: # https://github.com/actions/runner/issues/520 - if [[ -n "${{ secrets.STORE_LOGIN }}" ]]; then - echo "::set-output name=PUBLISH::true" - if [[ ${{ github.event_name }} == 'pull_request' ]]; then - echo "::set-output name=PUBLISH_BRANCH::edge/pr-${{ github.event.number }}" - else - echo "::set-output name=PUBLISH_BRANCH::" - fi + if [[ -n "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}" ]]; then + echo "::set-output name=PUBLISH::env" + elif [[ -n "${{ secrets.STORE_LOGIN }}" ]]; then + echo "::set-output name=PUBLISH::legacy" else echo "::set-output name=PUBLISH::" + + if [[ ${{ github.event_name }} == 'pull_request' ]]; then + echo "::set-output name=PUBLISH_BRANCH::edge/pr-${{ github.event.number }}" + else echo "::set-output name=PUBLISH_BRANCH::" fi @@ -43,9 +44,18 @@ jobs: # Make sure it is installable. sudo snap install --dangerous --classic ${{ steps.build-snapcraft.outputs.snap }} - - if: steps.decisions.outputs.PUBLISH == 'true' && steps.decisions.outputs.PUBLISH_BRANCH != null + - if: steps.decisions.outputs.PUBLISH == 'legacy' && steps.decisions.outputs.PUBLISH_BRANCH != null uses: snapcore/action-publish@v1 with: store_login: ${{ secrets.STORE_LOGIN }} snap: ${{ steps.build-snapcraft.outputs.snap }} release: ${{ steps.decisions.outputs.PUBLISH_BRANCH }} + + - if: steps.decisions.outputs.PUBLISH == 'env' && steps.decisions.outputs.PUBLISH_BRANCH != null + # Use this until snapcore/action-publish#27 it is merged. + uses: sergiusens/action-publish + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + with: + snap: ${{ steps.build-snapcraft.outputs.snap }} + release: ${{ steps.decisions.outputs.PUBLISH_BRANCH }} diff --git a/.github/workflows/spread.yml b/.github/workflows/spread.yml index b2fde6fa43..7478182844 100644 --- a/.github/workflows/spread.yml +++ b/.github/workflows/spread.yml @@ -40,6 +40,7 @@ jobs: spread-jobs: - google:ubuntu-18.04-64 - google:ubuntu-20.04-64 + - google:ubuntu-22.04-64 steps: - name: Checkout snapcraft @@ -84,7 +85,7 @@ jobs: run: | # Secrets cannot be used in conditionals, so this is our dance: # https://github.com/actions/runner/issues/520 - if [[ -n "${{ secrets.SNAP_STORE_MACAROON }}" ]]; then + if [[ -n "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS_STAGING }}" ]]; then echo "::set-output name=RUN::true" else echo "::set-output name=RUN::" @@ -107,8 +108,8 @@ jobs: name: Run spread env: SPREAD_GOOGLE_KEY: ${{ secrets.SPREAD_GOOGLE_KEY }} - SNAP_STORE_MACAROON: ${{ secrets.SNAP_STORE_MACAROON }} - SNAP_STORE_CANDID_MACAROON: ${{ secrets.SNAP_STORE_CANDID_MACAROON }} + SNAPCRAFT_STORE_CREDENTIALS_STAGING: "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS_STAGING }}" + SNAPCRAFT_STORE_CREDENTIALS_STAGING_CANDID: "${{ secrets.SNAPCRAFT_STORE_CREDENTIALS_STAGING_CANDID }}" run: spread google:ubuntu-18.04-64:tests/spread/general/store - name: Discard spread workers diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 430639925d..3ef064050d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,38 +1,89 @@ name: Python Environment Tests -on: [pull_request, push] +on: + push: + branches: + - "main" + - "snapcraft/7.0" + - "release/*" + pull_request: jobs: - static-and-unit-tests: + linters: runs-on: ubuntu-20.04 steps: - name: Checkout code uses: actions/checkout@v2 with: fetch-depth: 0 + - name: Set up Python 3.10 + uses: actions/setup-python@v2 + with: + python-version: "3.10" - name: Install dependencies run: | - ./tools/environment-setup-local.sh + sudo apt update + sudo apt install -y libapt-pkg-dev libyaml-dev xdelta3 shellcheck + pip install -U -r requirements.txt -r requirements-devel.txt + pip install . - name: Run black run: | make test-black - name: Run codespell run: | - source ${HOME}/.venv/snapcraft/bin/activate make test-codespell - name: Run flake8 run: | - source ${HOME}/.venv/snapcraft/bin/activate make test-flake8 + - name: Run isort + run: | + make test-isort - name: Run mypy run: | - source ${HOME}/.venv/snapcraft/bin/activate make test-mypy + - name: Run pydocstyle + run: | + make test-pydocstyle + - name: Run pyright + run: | + sudo snap install --classic node + sudo snap install --classic pyright + make test-pyright + - name: Run pylint + env: + SNAPCRAFT_IGNORE_YAML_BINDINGS: "1" + run: | + make test-pylint - name: Run shellcheck run: | make test-shellcheck + + tests: + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.10"] + + runs-on: ubuntu-20.04 + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + sudo apt update + sudo apt install -y libapt-pkg-dev libyaml-dev xdelta3 shellcheck + pip install -U wheel setuptools pip + pip install -U -r requirements.txt -r requirements-devel.txt + pip install . - name: Run unit tests + env: + SNAPCRAFT_IGNORE_YAML_BINDINGS: "1" run: | - source ${HOME}/.venv/snapcraft/bin/activate make test-units - name: Upload code coverage uses: codecov/codecov-action@v1 diff --git a/.gitignore b/.gitignore index 494dd067b4..3338ef47ad 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,7 @@ docs/reference.md htmlcov .idea .mypy_cache -parts +/parts pip-wheel-metadata/ prime *.pyc @@ -27,7 +27,6 @@ snap/.snapcraft/ stage *.swp target -tests/unit/parts/ tests/unit/snap/ tests/unit/stage/ .vscode diff --git a/Makefile b/Makefile index f4d4920012..902ecfd184 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ +SOURCES=setup.py snapcraft tests/*.py tests/unit + .PHONY: autoformat-black autoformat-black: - black . + black $(SOURCES) .PHONY: freeze-requirements freeze-requirements: @@ -8,19 +10,36 @@ freeze-requirements: .PHONY: test-black test-black: - black --check --diff . + black --check --diff $(SOURCES) .PHONY: test-codespell test-codespell: - codespell --quiet-level 4 --ignore-words-list crate,keyserver --skip '*.tar,*.xz,*.zip,*.bz2,*.7z,*.gz,*.deb,*.rpm,*.snap,*.gpg,*.pyc,*.png,*.ico,*.jar,changelog,.git,.hg,.mypy_cache,.tox,.venv,_build,buck-out,__pycache__,build,dist,.vscode,parts,stage,prime,test_appstream.py,./snapcraft.spec,./.direnv,./.pytest_cache' + codespell --quiet-level 4 --ignore-words-list crate,keyserver,comandos --skip '*.tar,*.xz,*.zip,*.bz2,*.7z,*.gz,*.deb,*.rpm,*.snap,*.gpg,*.pyc,*.png,*.ico,*.jar,*.so,changelog,.git,.hg,.mypy_cache,.tox,.venv,_build,buck-out,__pycache__,build,dist,.vscode,parts,stage,prime,test_appstream.py,./snapcraft.spec,./.direnv,./.pytest_cache' .PHONY: test-flake8 test-flake8: - python3 -m flake8 . + python3 -m flake8 $(SOURCES) + +.PHONY: test-isort +test-isort: + isort --check $(SOURCES) .PHONY: test-mypy test-mypy: - mypy . + mypy $(SOURCES) + +.PHONY: test-pydocstyle +test-pydocstyle: + pydocstyle snapcraft + +.PHONY: test-pylint +test-pylint: + pylint snapcraft + pylint tests/*.py tests/unit --disable=invalid-name,missing-module-docstring,missing-function-docstring,no-self-use,duplicate-code,protected-access,unspecified-encoding,too-many-public-methods,too-many-arguments + +.PHONY: test-pyright +test-pyright: + pyright $(SOURCES) .PHONY: test-shellcheck test-shellcheck: @@ -28,12 +47,19 @@ test-shellcheck: find . \( -name .git -o -name gradlew \) -prune -o -print0 | xargs -0 file -N | grep shell.script | cut -f1 -d: | xargs shellcheck ./tools/spread-shellcheck.py spread.yaml tests/spread/ +.PHONY: test-legacy-units +test-legacy-units: + pytest --cov-report=xml --cov=snapcraft tests/legacy/unit + .PHONY: test-units -test-units: +test-units: test-legacy-units pytest --cov-report=xml --cov=snapcraft tests/unit .PHONY: tests tests: tests-static test-units .PHONY: tests-static -tests-static: test-black test-codespell test-flake8 test-mypy test-shellcheck +tests-static: test-black test-codespell test-flake8 test-isort test-mypy test-pydocstyle test-pyright test-pylint test-shellcheck + +.PHONY: lint +lint: tests-static diff --git a/appveyor.yml b/appveyor.yml index eb0c1691e6..2dd633fc06 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,7 +7,7 @@ environment: TIMESTAMP_SERVICE: http://timestamp.digicert.com matrix: - - PYTHON: C:\Python37-x64 + - PYTHON: C:\Python38-x64 cache: - '%LOCALAPPDATA%\pip\Cache\http' @@ -27,7 +27,7 @@ build_script: - cmd: | echo "Building snapcraft.exe..." venv\Scripts\activate.bat - pyinstaller.exe --onefile snapcraft.spec + pyinstaller.exe --copy-metadata lazr.restfulclient --onefile snapcraft.spec venv\Scripts\deactivate.bat echo "Test signing snapcraft.exe..." @@ -58,7 +58,6 @@ build_script: test_script: - cmd: | echo "Smoke testing snapcraft.exe..." - dist\snapcraft.exe logout dist\snapcraft.exe version mkdir test cd test diff --git a/bin/snapcraftctl b/bin/snapcraftctl index 4971dbfe97..5eefd07828 100755 --- a/bin/snapcraftctl +++ b/bin/snapcraftctl @@ -30,10 +30,10 @@ quote() python3_command="${SNAPCRAFT_INTERPRETER:-$(command -v python3)}" snapcraftctl_command="$python3_command -I -c ' -import snapcraft.cli.__main__ +import snapcraft_legacy.cli.__main__ # Click strips off the first arg by default, so the -c will not be passed -snapcraft.cli.__main__.run_snapcraftctl(prog_name=\"snapcraftctl\") +snapcraft_legacy.cli.__main__.run_snapcraftctl(prog_name=\"snapcraftctl\") '" snapcraftctl_args=$(quote "$@") diff --git a/pyproject.toml b/pyproject.toml index 3f03696340..807102191f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,23 @@ force_grid_wrap = 0 use_parentheses = true ensure_newline_before_comments = true line_length = 88 + +[tool.pylint.messages_control] +# duplicate-code can't be disabled locally: https://github.com/PyCQA/pylint/issues/214 +disable = "too-few-public-methods,fixme,use-implicit-booleaness-not-comparison,duplicate-code" + +[tool.pylint.format] +max-attributes = 15 +max-args = 6 +good-names = "id" + +[tool.pylint.MASTER] +extension-pkg-allow-list = [ + "lxml.etree", + "pydantic", + "pytest", +] +load-plugins = "pylint_fixme_info,pylint_pytest" + +[tool.pylint.SIMILARITIES] +min-similarity-lines=10 diff --git a/requirements-devel.txt b/requirements-devel.txt index 99c05b1a07..5a3d31084a 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -1,91 +1,119 @@ -attrs==21.2.0 -catkin-pkg==0.4.23 -certifi==2021.5.30 -cffi==1.14.6 +astroid==2.11.4 +attrs==21.4.0 +black==22.3.0 +catkin-pkg==0.4.24 +certifi==2021.10.8 +cffi==1.15.0 chardet==4.0.0 -charset-normalizer==2.0.2 -click==8.0.1 +charset-normalizer==2.0.12 +click==8.1.3 codespell==2.1.0 -coverage==5.5 +coverage==6.3.2 +craft-cli==0.6.0 +craft-grammar==1.1.1 +craft-parts==1.6.1 +craft-providers==1.2.0 +craft-store==2.1.1 cryptography==3.4 -distro==1.5.0 -docutils==0.17.1 -entrypoints==0.3 +Deprecated==1.2.13 +dill==0.3.4 +distro==1.7.0 +docutils==0.18.1 extras==1.0.0 -fixtures==3.0.0 -flake8==3.7.9 +fixtures==4.0.0 +flake8==4.0.1 gnupg==2.3.1 -httplib2==0.19.1 +httplib2==0.20.4 hupper==1.10.3 -idna==3.2 -importlib-metadata==4.6.1 +idna==3.3 +importlib-metadata==4.11.3 iniconfig==1.1.1 -isort==5.9.2 -jeepney==0.7.0 +isort==5.10.1 +jeepney==0.8.0 jsonschema==2.5.1 -keyring==23.0.1 -launchpadlib==1.10.13 -lazr.restfulclient==0.14.3 -lazr.uri==1.0.5 -lxml==4.6.5 +keyring==23.5.0 +launchpadlib==1.10.16 +lazr.restfulclient==0.14.4 +lazr.uri==1.0.6 +lazy-object-proxy==1.7.1 +lxml==4.8.0 macaroonbakery==1.3.1 mccabe==0.6.1 -mypy==0.770 +mypy==0.950 mypy-extensions==0.4.3 -oauthlib==3.1.1 -packaging==21.0 +oauthlib==3.2.0 +overrides==6.1.0 +packaging==21.3 PasteDeploy==2.1.1 -pbr==5.6.0 +pathspec==0.9.0 +pbr==5.8.1 pexpect==4.8.0 plaster==1.0 plaster-pastedeploy==0.7 -pluggy==0.13.1 +platformdirs==2.5.2 +pluggy==1.0.0 progressbar==2.5 -protobuf==3.17.3 -psutil==5.8.0 +protobuf==3.20.1 +psutil==5.9.0 ptyprocess==0.7.0 -py==1.10.0 -pycodestyle==2.5.0 -pycparser==2.20 -pyelftools==0.27 -pyflakes==2.1.1 +py==1.11.0 +pycodestyle==2.8.0 +pycparser==2.21 +pydantic==1.9.0 +pydantic-yaml==0.6.3 +pydocstyle==6.1.1 +pyelftools==0.28 +pyflakes==2.4.0 pyftpdlib==1.5.6 -pylxd==2.3.0 +pylint==2.13.8 +pylint-fixme-info==1.0.3 +pylint-pytest==1.1.2 +pylxd==2.3.1 pymacaroons==0.13.0 -pyparsing==2.4.7 +pyparsing==3.0.8 pyramid==2.0 pyRFC3339==1.1 -pytest==6.2.4 -pytest-cov==2.12.1 -pytest-subprocess==1.1.1 +pytest==7.1.2 +pytest-cov==3.0.0 +pytest-mock==3.7.0 +pytest-subprocess==1.4.1 python-dateutil==2.8.2 -python-debian==0.1.40 -pytz==2021.1 +python-debian==0.1.43 +pytz==2022.1 pyxdg==0.27 -PyYAML==5.4 +PyYAML==6.0 raven==6.10.0 -requests==2.26.0 +requests==2.27.1 requests-toolbelt==0.9.1 -requests-unixsocket==0.2.0 -SecretStorage==3.3.1 -semantic-version==2.8.5 -simplejson==3.17.3 +requests-unixsocket==0.3.0 +SecretStorage==3.3.2 +semantic-version==2.9.0 +semver==2.13.0 +simplejson==3.17.6 six==1.16.0 +snowballstemmer==2.2.0 tabulate==0.8.9 -testresources==2.0.1 testscenarios==0.5.0 testtools==2.5.0 -tinydb==4.5.0 +tinydb==4.7.0 toml==0.10.2 +tomli==2.0.1 translationstring==1.4 -typed-ast==1.4.3 -typing-extensions==3.10.0.0 -urllib3==1.26.6 +types-Deprecated==1.2.7 +types-PyYAML==6.0.7 +types-requests==2.27.25 +types-setuptools==57.4.14 +types-tabulate==0.8.8 +types-urllib3==1.26.14 +typing-utils==0.1.0 +typing_extensions==4.2.0 +urllib3==1.26.9 venusian==3.0.0 -wadllib==1.3.5 +wadllib==1.3.6 WebOb==1.8.7 +wrapt==1.14.1 ws4py==0.5.1 -zipp==3.5.0 +zipp==3.8.0 zope.deprecation==4.4.0 zope.interface==5.4.0 python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.0.0ubuntu0.20.04.6/python-apt_2.0.0ubuntu0.20.04.6.tar.xz; sys.platform == "linux" diff --git a/requirements.txt b/requirements.txt index 6b0690ef89..5d47c15395 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,60 +1,72 @@ -attrs==21.2.0 -catkin-pkg==0.4.23 -certifi==2021.5.30 -cffi==1.14.6 +attrs==21.4.0 +catkin-pkg==0.4.24 +certifi==2021.10.8 +cffi==1.15.0 chardet==4.0.0 -charset-normalizer==2.0.2 -click==8.0.1 +charset-normalizer==2.0.12 +click==8.1.3 +craft-cli==0.6.0 +craft-grammar==1.1.1 +craft-parts==1.6.1 +craft-providers==1.2.0 +craft-store==2.1.1 cryptography==3.4 -distro==1.5.0 -docutils==0.17.1 +Deprecated==1.2.13 +distro==1.7.0 +docutils==0.18.1 gnupg==2.3.1 -httplib2==0.19.1 -idna==3.2 -importlib-metadata==4.6.1 -jeepney==0.7.0 +httplib2==0.20.4 +idna==3.3 +importlib-metadata==4.11.3 +jeepney==0.8.0 jsonschema==2.5.1 -keyring==23.0.1 -launchpadlib==1.10.13 -lazr.restfulclient==0.14.3 -lazr.uri==1.0.5 -lxml==4.6.5 +keyring==23.5.0 +launchpadlib==1.10.16 +lazr.restfulclient==0.14.4 +lazr.uri==1.0.6 +lxml==4.8.0 macaroonbakery==1.3.1 mypy-extensions==0.4.3 -oauthlib==3.1.1 -pbr==5.6.0 +oauthlib==3.2.0 +overrides==6.1.0 +platformdirs==2.5.2 progressbar==2.5 -protobuf==3.17.3 -psutil==5.8.0 -pycparser==2.20 -pyelftools==0.27 -pylxd==2.3.0 +protobuf==3.20.1 +psutil==5.9.0 +pycparser==2.21 +pydantic==1.9.0 +pydantic-yaml==0.6.3 +pyelftools==0.28 +pylxd==2.3.1 pymacaroons==0.13.0 -pyparsing==2.4.7 +pyparsing==3.0.8 pyRFC3339==1.1 python-dateutil==2.8.2 -python-debian==0.1.40 -pytz==2021.1 +python-debian==0.1.43 +pytz==2022.1 pyxdg==0.27 -PyYAML==5.4 +PyYAML==6.0 raven==6.10.0 -requests==2.26.0 +requests==2.27.1 requests-toolbelt==0.9.1 -requests-unixsocket==0.2.0 -SecretStorage==3.3.1 -semantic-version==2.8.5 -simplejson==3.17.3 +requests-unixsocket==0.3.0 +SecretStorage==3.3.2 +semantic-version==2.9.0 +semver==2.13.0 +simplejson==3.17.6 six==1.16.0 tabulate==0.8.9 -testresources==2.0.1 -tinydb==4.5.0 +tinydb==4.7.0 toml==0.10.2 -typing-extensions==3.10.0.0 -urllib3==1.26.6 -wadllib==1.3.5 +types-Deprecated==1.2.7 +typing-utils==0.1.0 +typing_extensions==4.2.0 +urllib3==1.26.9 +wadllib==1.3.6 +wrapt==1.14.1 ws4py==0.5.1 -zipp==3.5.0 +zipp==3.8.0 python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.0.0ubuntu0.20.04.6/python-apt_2.0.0ubuntu0.20.04.6.tar.xz; sys.platform == "linux" PyNaCl==1.4.0; sys.platform != "linux" PyNaCl @ https://files.pythonhosted.org/packages/61/ab/2ac6dea8489fa713e2b4c6c5b549cc962dd4a842b5998d9e80cf8440b7cd/PyNaCl-1.3.0.tar.gz; sys.platform == "linux" - +setuptools==49.6.0 diff --git a/schema/snapcraft.json b/schema/snapcraft.json index 286b0a9f31..2c0fe0d0db 100644 --- a/schema/snapcraft.json +++ b/schema/snapcraft.json @@ -832,7 +832,8 @@ "uniqueItems": true, "items": { "type": "string", - "pattern": "^[a-zA-Z0-9][-_.a-zA-Z0-9]*$" + "pattern": "^[a-zA-Z0-9][-_.a-zA-Z0-9]*$", + "validation-failure": "{.instance!r} is not a valid alias. Aliases must be strings, begin with an ASCII alphanumeric character, and can only use ASCII alphanumeric characters and the following special characters: . _ -" } }, "environment": { diff --git a/setup.cfg b/setup.cfg index 599afeeb6e..a80dbe21d5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,12 +1,9 @@ [flake8] -ignore = - # let black handle this - E501, - # http://hexbyteinc.com/ambv-black/#line-breaks--binary-operators - W503, - # http://hexbyteinc.com/ambv-black/#slices - E203 +# E501 line too long +# E203 whitespace before ':' +extend-ignore = E203, E501 max-complexity = 10 +max-line-length = 88 exclude = # No need to traverse our git directory .direnv, @@ -29,11 +26,17 @@ exclude = prime [mypy] -python_version = 3.6 +python_version = 3.8 ignore_missing_imports = True follow_imports = silent [pycodestyle] max-line-length = 88 -ignore = E501,W503,E203 +ignore = E203,E501 +[pydocstyle] +# D107 Missing docstring in __init__ (reason: documented in class docstring) +# D203 1 blank line required before class docstring (reason: pep257 default) +# D213 Multi-line docstring summary should start at the second line (reason: pep257 default) +ignore = D107, D203, D213 +ignore_decorators = overrides diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 2886a0bccb..eb5c449222 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2015-2021 Canonical Ltd +# Copyright 2015-2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -46,7 +46,7 @@ def recursive_data_files(directory, install_directory): "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.8", "Topic :: Software Development :: Build Tools", "Topic :: System :: Software Distribution", ] @@ -59,23 +59,33 @@ def recursive_data_files(directory, install_directory): scripts = [] dev_requires = [ + "black", "codespell", "coverage", - "flake8==3.7.9", - "pyflakes==2.1.1", + "flake8", + "pyflakes", "fixtures", "isort", "mccabe", - "mypy==0.770", + "mypy", "testscenarios", "pexpect", "pip", - "pycodestyle==2.5.0", + "pycodestyle", + "pydocstyle", "pyftpdlib", + "pylint", + "pylint-fixme-info", + "pylint-pytest", "pyramid", "pytest", "pytest-cov", + "pytest-mock", "pytest-subprocess", + "types-PyYAML", + "types-requests", + "types-setuptools", + "types-tabulate", ] if sys.platform == "win32": @@ -84,6 +94,11 @@ def recursive_data_files(directory, install_directory): install_requires = [ "attrs", "click", + "craft-cli", + "craft-grammar", + "craft-parts", + "craft-providers", + "craft-store", "cryptography==3.4", "gnupg", "jsonschema==2.5.1", @@ -92,17 +107,19 @@ def recursive_data_files(directory, install_directory): "lxml", "macaroonbakery", "mypy-extensions", + "overrides", "progressbar", "pyelftools", "pymacaroons", "pyxdg", - "pyyaml==5.4", + "pyyaml", "raven", "requests-toolbelt", "requests-unixsocket", "requests", "simplejson", "tabulate", + "toml", "tinydb", "typing-extensions", ] @@ -139,7 +156,12 @@ def recursive_data_files(directory, install_directory): license=license, classifiers=classifiers, scripts=scripts, - entry_points=dict(console_scripts=["snapcraft = snapcraft.cli.__main__:run"]), + entry_points=dict( + console_scripts=[ + "snapcraft_legacy = snapcraft_legacy.cli.__main__:run", + "snapcraft = snapcraft.cli:run", + ] + ), data_files=( recursive_data_files("schema", "share/snapcraft") + recursive_data_files("keyrings", "share/snapcraft") diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index f0ea19cd1c..5000dafd10 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -15,7 +15,7 @@ assumes: apps: snapcraft: environment: - PATH: "/snap/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + PATH: "$SNAP/libexec/snapcraft:/snap/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" # https://github.com/lxc/pylxd/pull/361 PYLXD_WARNINGS: "none" command: bin/python $SNAP/bin/snapcraft @@ -134,4 +134,7 @@ parts: [ -n "$(echo $version | grep "+git")" ] && grade=devel || grade=stable snapcraftctl set-grade "$grade" ln -sf ../usr/bin/python3.8 $SNAPCRAFT_PART_INSTALL/bin/python3 + mkdir -p $SNAPCRAFT_PART_INSTALL/libexec/snapcraft + mv $SNAPCRAFT_PART_INSTALL/bin/craftctl $SNAPCRAFT_PART_INSTALL/libexec/snapcraft/ + sed -i -e '1 s|^#!/.*|#!/snap/snapcraft/current/bin/python|' $SNAPCRAFT_PART_INSTALL/libexec/snapcraft/craftctl after: [snapcraft-libs] diff --git a/snapcraft.spec b/snapcraft.spec index b3c2a0bb76..5ff5639fbe 100644 --- a/snapcraft.spec +++ b/snapcraft.spec @@ -1,5 +1,5 @@ # -*- mode: python ; coding: utf-8 -*- -from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import collect_data_files, copy_metadata block_cipher = None @@ -12,9 +12,13 @@ data += collect_data_files("launchpadlib") data += collect_data_files("lazr.restfulclient") data += collect_data_files("lazr.uri") data += collect_data_files("wadllib") +data += copy_metadata("launchpadlib") +data += copy_metadata("lazr.restfulclient") +data += copy_metadata("lazr.uri") +data += copy_metadata("wadllib") a = Analysis( - ["snapcraft\\cli\\__main__.py"], + ["snapcraft_legacy\\cli\\__main__.py"], pathex=[], binaries=[], datas=data, diff --git a/snapcraft/__init__.py b/snapcraft/__init__.py index 8613351eaa..6c9a0a516e 100644 --- a/snapcraft/__init__.py +++ b/snapcraft/__init__.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2015-2017, 2020 Canonical Ltd +# Copyright 2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,357 +14,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Snapcraft plugins drive different build systems +"""Publish your app for Linux users for desktop, cloud, and IoT.""" -Each part has a build system . Most parts are built from source using one of -a range of build systems such as CMake or Scons. Some parts are pre-built -and just copied into place, for example parts that reuse existing binary -packages. +import os -You tell snapcraft which build system it must drive by specifying the -snapcraft plugin for that part. Every part must specify a plugin explicitly -(when you see a part that does not specify a plugin, that's because the -actual part definition is in the cloud, where the plugin is specified!) - -These plugins implement a lifecycle over the following steps: - - - pull: retrieve the source for the part from the specified location - - build: drive the build system determined by the choice of plugin - - stage: consolidate desirable files from all the parts in one tree - - prime: distill down to only the files which will go into the snap - - snap: compress the prime tree into the installable snap file - -These steps correspond to snapcraft commands. So when you initiate a -'snapcraft pull' you will invoke the respective plugin for each part in -the snap, in sequence, to handle the source pull. Each part will then have a -fully populated parts//src/ directory. Similarly, if you then say -'snapcraft build' you will invoke the plugin responsible for each part in -turn, to build the part. - -# Snapcraft Lifecycle - -## Pull - -In this first step, source material is retrieved from the specified -location, whether that is a URL for a tarball, a local path to a source tree -inside the snap, a revision control reference to checkout, or something -specific to the plugin such as PyPI. The plugin might also download -necessary artifacts, such as the Java SDK, which are not specific to the -particular part but which are needed by the plugin to handle its type of -build system. - -All the downloaded content for each part goes into the -`parts//src/` directory, which acts as a cache to prevent -re-fetching content. You can clean that cache out with 'snapcraft clean'. - -## Build - -Snapcraft calculates an appropriate sequence to build the parts, based on -explicit 'after' references and the order of the parts in the -snapcraft.yaml. Each part is built in the `parts//build` -directory and installed into `parts//install`. - -Note the install step - we might actually want to use built artifacts from -one part in the build process of another, so the `parts//install` -directory is useful as a 'working fresh install' of the part. - -Between the plugin, the part definition YAML, and the build system of the -part, it is expected that the part can be built and installed in the right -place. - -At this point you have a tree under `parts/` with a subdirectory for every -part, and underneath those, separate src, build and install trees for each -part. - -## Stage - -We now need to start consolidating the important pieces of each part into a -single tree. We do this twice - once in a very sweeping way that will -produce a lot of extraneous materials but is useful for debugging. This is -the 'stage' step of the lifecycle, because we move a lot of the build output -from each part into a consolidated tree under `stage/` which has the -structure of a snap but has way too much extra information. - -The important thing about the staging area is that it lets you get all the -shared libraries in one place and lets you find overlapping content in the -parts. You can also try this directory as if it were a snap, and you'll have -all the debugging information in the tree, which is useful for developers. - -Each part describes its own staging content - the files that should be -staged. The part will often describe "chunks" of content, called filesets, -so that they can be referred to as a useful set rather than having to call -out individual files. - -## Prime - -It is useful to have a directory tree which exactly mirrors the structure of -the final snap. This is the `prime/` directory, and the lifecycle includes a -'prime' step which copies only that final, required content from the -`stage/` directory into the `prime/` directory. - -So the `prime/` directory contains only the content that will be put into -the final snap, unlike the staging area which may include debug and -development files not destined for your snap. - -The snap metadata will also be placed in `./prime/meta` during the prime -step, so this `./prime` directory is useful for inspecting exactly what is -going into your snap or to conduct any final post-processing on snapcraft's -output. - -## Snap - -The final step in the snapcraft lifecycle builds a snap out of the `prime/` -directory. It will be in the top level directory, alongside snapcraft.yaml, -called --.snap - - -# Standard part definition keywords - -There are several builtin keywords which can be used in any part regardless -of the choice of plugin. - - - after: [part, part, part...] - - Snapcraft will make sure that it builds all of the listed parts before - it tries to build this part. Essentially these listed dependencies for - this part, useful when the part needs a library or tool built by another - part. - - If such a dependency part is not defined in this snapcraft.yaml, it must - be defined in the cloud parts library, and snapcraft will retrieve the - definition of the part from the cloud. In this way, a shared library of - parts is available to every snap author - just say 'after' and list the - parts you want that others have already defined. - - - build-packages: [pkg, pkg, pkg...] - - A list of packages to install on the build host before building - the part. The files from these packages typically will not go into the - final snap unless they contain libraries that are direct dependencies of - binaries within the snap (in which case they'll be discovered via `ldd`), - or they are explicitly described in stage-packages. - - - stage-packages: YAML list - - A set of packages to be downloaded and unpacked to join the part - before it's built. Note that these packages are not installed on the host. - Like the rest of the part, all files from these packages will make it into - the final snap unless filtered out via the `snap` keyword. - - One may simply specify packages in a flat list, in which case the packages - will be fetched and unpacked regardless of build environment. In addition, - a specific grammar made up of sub-lists is supported here that allows one - to filter stage packages depending on various selectors (e.g. the target - arch), as well as specify optional packages. The grammar is made up of two - nestable statements: 'on' and 'try'. - - Let's discuss `on`. - - - on [,...]: - - ... - - else[ fail]: - - ... - - The body of the 'on' clause is taken into account if every (AND, not OR) - selector is true for the target build environment. Currently the only - selectors supported are target architectures (e.g. amd64). - - If the 'on' clause doesn't match and it's immediately followed by an 'else' - clause, the 'else' clause must be satisfied. An 'on' clause without an - 'else' clause is considered satisfied even if no selector matched. The - 'else fail' form allows erroring out if an 'on' clause was not matched. - - For example, say you only wanted to stage `foo` if building for amd64 (and - not stage `foo` if otherwise): - - - on amd64: [foo] - - Building on that, say you wanted to stage `bar` if building on an arch - other than amd64: - - - on amd64: [foo] - - else: [bar] - - You can nest these for more complex behaviors: - - - on amd64: [foo] - - else: - - on i386: [bar] - - on armhf: [baz] - - If your project requires a package that is only available on amd64, you can - fail if you're not building for amd64: - - - on amd64: [foo] - - else fail - - Now let's discuss `try`: - - - try: - - ... - - else: - - ... - - The body of the 'try' clause is taken into account only when all packages - contained within it are valid. If not, if it's immediately followed by - 'else' clauses they are tried in order, and one of them must be satisfied. - A 'try' clause with no 'else' clause is considered satisfied even if it - contains invalid packages. - - For example, say you wanted to stage `foo`, but it wasn't available for all - architectures. Assuming your project builds without it, you can make it an - optional stage package: - - - try: [foo] - - You can also add alternatives: - - - try: [foo] - - else: [bar] - - Again, you can nest these for more complex behaviors: - - - on amd64: [foo] - - else: - - try: [bar] - - - organize: YAML - - Snapcraft will rename files according to this YAML sub-section. The - content of the 'organize' section consists of old path keys, and their - new values after the renaming. - - This can be used to avoid conflicts between parts that use the same - name, or to map content from different parts into a common conventional - file structure. For example: - - organize: - usr/oldfilename: usr/newfilename - usr/local/share/: usr/share/ - - The key is the internal part filename, the value is the exposed filename - that will be used during the staging process. You can rename whole - subtrees of the part, or just specific files. - - Note that the path is relative (even though it is "usr/local") because - it refers to content underneath parts//install which is going - to be mapped into the stage and prime areas. - - - filesets: YAML - - When we map files into the stage and prime areas on the way to putting - them into the snap, it is convenient to be able to refer to groups of - files as well as individual files. Snapcraft lets you name a fileset - and then use it later for inclusion or exclusion of those files from the - resulting snap. - - For example, consider man pages of header files.. You might want them - in, or you might want to leave them out, but you definitely don't want - to repeatedly have to list all of them either way. - - This section is thus a YAML map of fileset names (the keys) to a list of - filenames. The list is built up by adding individual files or whole - subdirectory paths (and all the files under that path) and wildcard - globs, and then pruning from those paths. - - The wildcard * globs all files in that path. Exclusions are denoted by - an initial `-`. - - For example you could add usr/local/* then remove usr/local/man/*: - - filesets: - allbutman: [ usr/local/*, -usr/local/man/* ] - manpages: [ usr/local/man ] - - Filenames are relative to the part install directory in - `parts//install`. If you have used 'organize' to rename files - then the filesets will be built up from the names after organization. - - - stage: YAML file and fileset list - - A list of files from a part install directory to copy into `stage/`. - Rules applying to the list here are the same as those of filesets. - Referencing of fileset keys is done with a $ prefixing the fileset key, - which will expand with the value of such key. - - For example: - - stage: - - usr/lib/* # Everything under parts//install/usr/lib - - -usr/lib/libtest.so # Excludng libtest.so - - $manpages # Including the 'manpages' fileset - - - snap: YAML file and fileset list - - A list of files from a part install directory to copy into `prime/`. - This section takes exactly the same form as the 'stage' section but the - files identified here will go into the ultimate snap (because the - `prime/` directory reflects the file structure of the snap with no - extraneous content). - - - build-attributes: [attribute1, attribute2] - - A list of special attributes that affect the build of this specific part. - Supported attributes: - - - no-install: - Do not run the install target provided by the plugin's build system. - - Supported by: kbuild - - - debug: - Plugins that support the concept of build types build in Release mode - by default. Setting the 'debug' attribute requests that they instead - build in Debug mode. -""" - -from collections import OrderedDict # noqa - -import pkg_resources # noqa +import pkg_resources def _get_version(): - import os as _os - - if _os.environ.get("SNAP_NAME") == "snapcraft": - return _os.environ["SNAP_VERSION"] + if os.environ.get("SNAP_NAME") == "snapcraft": + return os.environ["SNAP_VERSION"] try: return pkg_resources.require("snapcraft")[0].version except pkg_resources.DistributionNotFound: return "devel" -# Set this early so that the circular imports aren't too painful __version__ = _get_version() - -# Workaround for potential import loops. -from snapcraft.internal import repo # noqa isort:skip - -# For backwards compatibility with external plugins. -import snapcraft._legacy_loader # noqa: F401 isort:skip -from snapcraft.plugins.v1 import PluginV1 as BasePlugin # noqa: F401 isort:skip -from snapcraft import common # noqa -from snapcraft import extractors # noqa -from snapcraft import file_utils # noqa -from snapcraft import plugins # noqa -from snapcraft import shell_utils # noqa -from snapcraft import sources # noqa - -# FIXME LP: #1662658 -from snapcraft._store import ( # noqa - create_key, - download, - gated, - list_keys, - list_registered, - login, - register, - register_key, - sign_build, - status, - upload, - upload_metadata, - validate, -) - -from snapcraft.project._project_options import ProjectOptions # noqa isort:skip diff --git a/snapcraft/__main__.py b/snapcraft/__main__.py new file mode 100644 index 0000000000..8675237d10 --- /dev/null +++ b/snapcraft/__main__.py @@ -0,0 +1,23 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Main entry point.""" + +import sys + +from snapcraft import cli + +sys.exit(cli.run()) diff --git a/snapcraft/cli.py b/snapcraft/cli.py new file mode 100644 index 0000000000..b5142f60a9 --- /dev/null +++ b/snapcraft/cli.py @@ -0,0 +1,192 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Command-line application entry point.""" + +import contextlib +import logging +import os +import sys + +import craft_cli +import craft_store +from craft_cli import ArgumentParsingError, EmitterMode, ProvideHelpException, emit + +from snapcraft import __version__, errors, utils +from snapcraft_legacy.cli import legacy + +from . import commands + +COMMAND_GROUPS = [ + craft_cli.CommandGroup( + "Lifecycle", + [ + commands.CleanCommand, + commands.PullCommand, + commands.BuildCommand, + commands.StageCommand, + commands.PrimeCommand, + commands.PackCommand, + commands.SnapCommand, # hidden (legacy compatibility) + commands.StoreLegacyRemoteBuildCommand, + ], + ), + craft_cli.CommandGroup( + "Extensions", + [ + commands.ListExtensionsCommand, + commands.ExtensionsCommand, # hidden (alias to list-extensions) + commands.ExpandExtensionsCommand, + ], + ), + craft_cli.CommandGroup( + "Store Account", + [ + commands.StoreLoginCommand, + commands.StoreExportLoginCommand, + commands.StoreLogoutCommand, + commands.StoreWhoAmICommand, + ], + ), + craft_cli.CommandGroup( + "Store Snap Names", + [ + commands.StoreRegisterCommand, + commands.StoreNamesCommand, + commands.StoreLegacyListRegisteredCommand, + commands.StoreLegacyListCommand, + commands.StoreLegacyMetricsCommand, + commands.StoreLegacyUploadMetadataCommand, + ], + ), + craft_cli.CommandGroup( + "Store Snap Release Management", + [ + commands.StoreReleaseCommand, + commands.StoreCloseCommand, + commands.StoreStatusCommand, + commands.StoreUploadCommand, + commands.StoreLegacyPromoteCommand, + commands.StoreLegacyListRevisionsCommand, + ], + ), + craft_cli.CommandGroup( + "Store Snap Tracks", + [ + commands.StoreListTracksCommand, + commands.StoreTracksCommand, # hidden (alias to list-tracks) + commands.StoreLegacySetDefaultTrackCommand, + ], + ), + craft_cli.CommandGroup( + "Store Assertions", + [ + commands.StoreLegacyCreateKeyCommand, + commands.StoreLegacyEditValidationSetsCommand, + commands.StoreLegacyGatedCommand, + commands.StoreLegacyListValidationSetsCommand, + commands.StoreLegacyRegisterKeyCommand, + commands.StoreLegacySignBuildCommand, + commands.StoreLegacyValidateCommand, + commands.StoreLegacyListKeysCommand, + ], + ), + craft_cli.CommandGroup("Other", [commands.VersionCommand]), +] + +GLOBAL_ARGS = [ + craft_cli.GlobalArgument( + "version", "flag", "-V", "--version", "Show the application version and exit" + ) +] + + +def get_dispatcher() -> craft_cli.Dispatcher: + """Return an instance of Dispatcher. + + Run all the checks and setup required to ensure the Dispatcher can run. + """ + # Run the legacy implementation if inside a legacy managed environment. + if os.getenv("SNAPCRAFT_BUILD_ENVIRONMENT") == "managed-host": + legacy.legacy_run() + + # set lib loggers to debug level so that all messages are sent to Emitter + for lib_name in ("craft_parts", "craft_providers"): + logger = logging.getLogger(lib_name) + logger.setLevel(logging.DEBUG) + + if utils.is_managed_mode(): + log_filepath = utils.get_managed_environment_log_path() + else: + log_filepath = None + + emit.init( + mode=EmitterMode.NORMAL, + appname="snapcraft", + greeting=f"Starting Snapcraft {__version__}", + log_filepath=log_filepath, + ) + + return craft_cli.Dispatcher( + "snapcraft", + COMMAND_GROUPS, + summary="Package, distribute, and update snaps for Linux and IoT", + extra_global_args=GLOBAL_ARGS, + default_command=commands.PackCommand, + ) + + +def run(): + """Run the CLI.""" + dispatcher = get_dispatcher() + try: + global_args = dispatcher.pre_parse_args(sys.argv[1:]) + if global_args.get("version"): + emit.message(f"snapcraft {__version__}") + else: + dispatcher.load_command(None) + dispatcher.run() + emit.ended_ok() + retcode = 0 + except ArgumentParsingError as err: + # TODO https://github.com/canonical/craft-cli/issues/78 + with contextlib.suppress(KeyError, IndexError): + if ( + err.__context__ is not None + and err.__context__.args[0] not in dispatcher.commands + ): + emit.trace(f"run legacy implementation: {err!s}") + emit.ended_ok() + legacy.legacy_run() + print(err, file=sys.stderr) # to stderr, as argparse normally does + emit.ended_ok() + retcode = 1 + except ProvideHelpException as err: + print(err, file=sys.stderr) # to stderr, as argparse normally does + emit.ended_ok() + retcode = 0 + except errors.LegacyFallback as err: + emit.trace(f"run legacy implementation: {err!s}") + emit.ended_ok() + legacy.legacy_run() + except craft_store.errors.CraftStoreError as err: + emit.error(craft_cli.errors.CraftError(f"craft-store error: {err}")) + retcode = 1 + except errors.SnapcraftError as err: + emit.error(err) + retcode = 1 + + return retcode diff --git a/snapcraft/cli/store.py b/snapcraft/cli/store.py deleted file mode 100644 index efe2fbc354..0000000000 --- a/snapcraft/cli/store.py +++ /dev/null @@ -1,997 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2016-2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import functools -import json -import operator -import os -import stat -import sys -from datetime import date, timedelta -from textwrap import dedent -from typing import Dict, List, Optional, Set, Union - -import click -from tabulate import tabulate - -import snapcraft -from snapcraft import formatting_utils, storeapi -from snapcraft._store import StoreClientCLI -from snapcraft.storeapi import metrics as metrics_module -from snapcraft.storeapi.constants import DEFAULT_SERIES - -from . import echo -from ._channel_map import get_tabulated_channel_map -from ._metrics import convert_metrics_to_table -from ._review import review_snap - -_MESSAGE_REGISTER_PRIVATE = dedent( - """\ - Even though this is private snap, you should think carefully about - the choice of name and make sure you are confident nobody else will - have a stronger claim to that particular name. If you are unsure - then we suggest you prefix the name with your developer identity, - As '$username-yoyodyne-www-site-content'.""" -) -_MESSAGE_REGISTER_CONFIRM = dedent( - """ - We always want to ensure that users get the software they expect - for a particular name. - - If needed, we will rename snaps to ensure that a particular name - reflects the software most widely expected by our community. - - For example, most people would expect 'thunderbird' to be published by - Mozilla. They would also expect to be able to get other snaps of - Thunderbird as '$username-thunderbird'. - - Would you say that MOST users will expect {!r} to come from - you, and be the software you intend to publish there?""" -) -_MESSAGE_REGISTER_SUCCESS = "Congrats! You are now the publisher of {!r}." -_MESSAGE_REGISTER_NO = dedent( - """ - Thank you! {!r} will remain available. - - In the meantime you can register an alternative name.""" -) - - -@click.group() -def storecli(): - """Store commands""" - - -def _human_readable_acls(store_client: storeapi.StoreClient) -> str: - acl = store_client.acl() - snap_names = [] - snap_ids = acl["snap_ids"] - - if snap_ids is not None: - try: - for snap_id in snap_ids: - snap_names.append(store_client.get_snap_name_for_id(snap_id)) - except TypeError: - raise RuntimeError(f"invalid snap_ids: {snap_ids!r}") - acl["snap_names"] = snap_names - else: - acl["snap_names"] = None - - human_readable_acl: Dict[str, Union[str, List[str], None]] = { - "expires": str(acl["expires"]) - } - - for key in ("snap_names", "channels", "permissions"): - human_readable_acl[key] = acl[key] - if not acl[key]: - human_readable_acl[key] = "No restriction" - - return dedent( - """\ - snaps: {snap_names} - channels: {channels} - permissions: {permissions} - expires: {expires} - """.format( - **human_readable_acl - ) - ) - - -@storecli.command() -@click.argument("snap-name", metavar="") -@click.option("--private", is_flag=True, help="Register the snap as a private one") -@click.option("--store", metavar="", help="Store to register with") -@click.option("--yes", is_flag=True) -def register(snap_name, private, store, yes): - """Register with the store. - - You can use this command to register an available and become - the publisher for this snap. - - \b - Examples: - snapcraft register thunderbird - """ - if private: - click.echo(_MESSAGE_REGISTER_PRIVATE.format(snap_name)) - if yes or echo.confirm(_MESSAGE_REGISTER_CONFIRM.format(snap_name)): - snapcraft.register(snap_name, is_private=private, store_id=store) - click.echo(_MESSAGE_REGISTER_SUCCESS.format(snap_name)) - else: - click.echo(_MESSAGE_REGISTER_NO.format(snap_name)) - - -@storecli.command() -@click.option( - "--release", - metavar="", - help="Optional comma separated list of channels to release ", -) -@click.argument( - "snap-file", - metavar="", - type=click.Path(exists=True, readable=True, resolve_path=True, dir_okay=False), -) -def upload(snap_file, release): - """Upload to the store. - - By passing --release with a comma separated list of channels the snap would - be released to the selected channels if the store review passes for this - . - - This operation will block until the store finishes processing this - . - - If --release is used, the channel map will be displayed after the - operation takes place. - - \b - Examples: - snapcraft upload my-snap_0.1_amd64.snap - snapcraft upload my-snap_0.2_amd64.snap --release edge - snapcraft upload my-snap_0.3_amd64.snap --release candidate,beta - """ - click.echo("Preparing to upload {!r}.".format(os.path.basename(snap_file))) - if release: - channel_list = release.split(",") - click.echo( - "After uploading, the resulting snap revision will be released to " - "{} when it passes the Snap Store review." - "".format(formatting_utils.humanize_list(channel_list, "and")) - ) - else: - channel_list = None - - review_snap(snap_file=snap_file) - snap_name, snap_revision = snapcraft.upload(snap_file, channel_list) - - echo.info("Revision {!r} of {!r} created.".format(snap_revision, snap_name)) - if channel_list: - store_client_cli = StoreClientCLI() - snap_channel_map = store_client_cli.get_snap_channel_map(snap_name=snap_name) - - click.echo( - get_tabulated_channel_map( - snap_channel_map, - architectures=snap_channel_map.get_revision( - snap_revision - ).architectures, - ) - ) - - -@storecli.command("upload-metadata") -@click.option( - "--force", - is_flag=True, - help="Force metadata update to override any possible conflict", -) -@click.argument( - "snap-file", - metavar="", - type=click.Path(exists=True, readable=True, resolve_path=True, dir_okay=False), -) -def upload_metadata(snap_file, force): - """Upload metadata from to the store. - - The following information will be retrieved from and used - to update the store: - - \b - - summary - - description - - icon - - If --force is given, it will force the local metadata into the Store, - ignoring any possible conflict. - - \b - Examples: - snapcraft upload-metadata my-snap_0.1_amd64.snap - snapcraft upload-metadata my-snap_0.1_amd64.snap --force - """ - click.echo("Uploading metadata from {!r}".format(os.path.basename(snap_file))) - snapcraft.upload_metadata(snap_file, force) - - -@storecli.command() -@click.argument("snap-name", metavar="") -@click.argument("revision", metavar="") -@click.argument("channels", metavar="") -@click.option( - "--progressive", - type=click.IntRange(0, 100), - default=100, - metavar="", - help="set a release progression to a certain percentage.", -) -@click.option( - "--experimental-progressive-releases", - is_flag=True, - help="*EXPERIMENTAL* Enables 'progressive releases'.", - envvar="SNAPCRAFT_EXPERIMENTAL_PROGRESSIVE_RELEASES", -) -def release( - snap_name, - revision, - channels, - progressive: Optional[int], - experimental_progressive_releases: bool, -) -> None: - """Release on to the selected store . - is a comma separated list of valid channels on the - store. - - The must exist on the store, to see available revisions - run `snapcraft list-revisions `. - - The channel map will be displayed after the operation takes place. - To see the status map at any other time run `snapcraft status `. - - The format for channels is `[/][/]` where - - \b - - is used to have long term release channels. It is implicitly - set to `latest`. If this snap requires one, it can be created by - request by having a conversation on https://forum.snapcraft.io - under the *store* category. - - is mandatory and can be either `stable`, `candidate`, `beta` - or `edge`. - - is optional and dynamically creates a channel with a - specific expiration date. - - \b - Examples: - snapcraft release my-snap 8 stable - snapcraft release my-snap 8 stable/my-branch - snapcraft release my-snap 9 beta,edge - snapcraft release my-snap 9 lts-channel/stable - snapcraft release my-snap 9 lts-channel/stable/my-branch - """ - # If progressive is set to 100, treat it as None. - if progressive == 100: - progressive = None - - if progressive is not None and not experimental_progressive_releases: - raise click.UsageError( - "--progressive requires --experimental-progressive-releases." - ) - elif progressive: - os.environ["SNAPCRAFT_EXPERIMENTAL_PROGRESSIVE_RELEASES"] = "Y" - echo.warning("*EXPERIMENTAL* progressive releases in use.") - - store_client_cli = StoreClientCLI() - release_data = store_client_cli.release( - snap_name=snap_name, - revision=revision, - channels=channels.split(","), - progressive_percentage=progressive, - ) - snap_channel_map = store_client_cli.get_snap_channel_map(snap_name=snap_name) - architectures_for_revision = snap_channel_map.get_revision( - int(revision) - ).architectures - tracks = [storeapi.channels.Channel(c).track for c in channels.split(",")] - click.echo( - get_tabulated_channel_map( - snap_channel_map, tracks=tracks, architectures=architectures_for_revision - ) - ) - - opened_channels = release_data.get("opened_channels", []) - if len(opened_channels) == 1: - echo.info(f"The {opened_channels[0]!r} channel is now open.") - elif len(opened_channels) > 1: - channels = ("{!r}".format(channel) for channel in opened_channels[:-1]) - echo.info( - "The {} and {!r} channels are now open.".format( - ", ".join(channels), opened_channels[-1] - ) - ) - - -@storecli.command() -@click.argument("snap-name", metavar="") -@click.option( - "--from-channel", - metavar="", - required=True, - help="The channel to promote from.", -) -@click.option( - "--to-channel", - metavar="", - required=True, - help="The channel to promote to.", -) -@click.option("--yes", is_flag=True, help="Do not prompt for confirmation.") -def promote(snap_name, from_channel, to_channel, yes): - """Promote a build set from to a channel. - - A build set is a set of commonly tagged revisions, the most simple - form of a build set is a set of revisions released to a channel. - - Currently, only channels are supported to release from () - - Prior to releasing, visual confirmation shall be required. - - The format for channels is `[/][/]` where - - \b - - is used to have long term release channels. It is implicitly - set to the default. - - is mandatory and can be either `stable`, `candidate`, `beta` - or `edge`. - - is optional and dynamically creates a channel with a - specific expiration date. - - \b - Examples: - snapcraft promote my-snap --from-channel candidate --to-channel stable - snapcraft promote my-snap --from-channel lts/candidate --to-channel lts/stable - snapcraft promote my-snap --from-channel stable/patch --to-channel stable - snapcraft promote my-snap --from-channel experimental/stable --to-channel stable - """ - echo.warning( - "snapcraft promote does not have a stable CLI interface. Use with caution in scripts." - ) - parsed_from_channel = storeapi.channels.Channel(from_channel) - parsed_to_channel = storeapi.channels.Channel(to_channel) - - if parsed_from_channel == parsed_to_channel: - raise click.BadOptionUsage( - "--to-channel", "--from-channel and --to-channel cannot be the same." - ) - elif ( - parsed_from_channel.risk == "edge" - and parsed_from_channel.branch is None - and yes - ): - raise click.BadOptionUsage( - "--from-channel", - "{!r} is not a valid set value for --from-channel when using --yes.".format( - parsed_from_channel - ), - ) - - store = storeapi.StoreClient() - status_payload = store.get_snap_status(snap_name) - - snap_status = storeapi.status.SnapStatus( - snap_name=snap_name, payload=status_payload - ) - from_channel_set = snap_status.get_channel_set(parsed_from_channel) - echo.info("Build set information for {!r}".format(parsed_from_channel)) - click.echo( - tabulate( - sorted(from_channel_set, key=operator.attrgetter("arch")), - headers=["Arch", "Revision", "Version"], - tablefmt="plain", - ) - ) - if yes or echo.confirm( - "Do you want to promote the current set to the {!r} channel?".format( - parsed_to_channel - ) - ): - for c in from_channel_set: - store.release( - snap_name=snap_name, - revision=str(c.revision), - channels=[str(parsed_to_channel)], - ) - snap_channel_map = store.get_snap_channel_map(snap_name=snap_name) - existing_architectures = snap_channel_map.get_existing_architectures() - click.echo( - get_tabulated_channel_map( - snap_channel_map, - tracks=[parsed_to_channel.track], - architectures=existing_architectures, - ) - ) - else: - echo.wrapped("Channel promotion cancelled") - - -@storecli.command() -@click.argument("snap-name", metavar="") -@click.argument("channels", metavar="...", nargs=-1) -def close(snap_name, channels): - """Close for . - Closing a channel allows the that is closed to track the channel - that follows it in the channel release chain. As such closing the - 'candidate' channel would make it track the 'stable' channel. - - The channel map will be displayed after the operation takes place. - - \b - Examples: - snapcraft close my-snap beta - snapcraft close my-snap beta edge - """ - store = storeapi.StoreClient() - account_info = store.get_account_information() - - try: - snap_id = account_info["snaps"][DEFAULT_SERIES][snap_name]["snap-id"] - except KeyError: - raise storeapi.errors.StoreChannelClosingPermissionError( - snap_name, DEFAULT_SERIES - ) - - # Returned closed_channels cannot be trusted as it returns risks. - store.close_channels(snap_id=snap_id, channel_names=channels) - if len(channels) == 1: - msg = "The {} channel is now closed.".format(channels[0]) - else: - msg = "The {} and {} channels are now closed.".format( - ", ".join(channels[:-1]), channels[-1] - ) - - snap_channel_map = store.get_snap_channel_map(snap_name=snap_name) - if snap_channel_map.channel_map: - closed_tracks = {storeapi.channels.Channel(c).track for c in channels} - existing_architectures = snap_channel_map.get_existing_architectures() - - click.echo( - get_tabulated_channel_map( - snap_channel_map, - architectures=existing_architectures, - tracks=closed_tracks, - ) - ) - click.echo() - - echo.info(msg) - - -@storecli.command() -@click.option( - "--experimental-progressive-releases", - is_flag=True, - help="*EXPERIMENTAL* Enables 'progressive releases'.", - envvar="SNAPCRAFT_EXPERIMENTAL_PROGRESSIVE_RELEASES", -) -@click.option( - "architectures", - "--arch", - metavar="", - multiple=True, - help="Limit status to these architectures (can specify multiple times)", -) -@click.option( - "tracks", - "--track", - multiple=True, - metavar="", - help="Limit status to these tracks (can specify multiple times)", -) -@click.argument("snap-name", metavar="") -def status(snap_name, architectures, tracks, experimental_progressive_releases): - """Get the status on the store for . - - \b - Examples: - snapcraft status my-snap - snapcraft status --track 20 my-snap - snapcraft status --arch amd64 my-snap - """ - if experimental_progressive_releases: - os.environ["SNAPCRAFT_EXPERIMENTAL_PROGRESSIVE_RELEASES"] = "Y" - echo.warning("*EXPERIMENTAL* progressive releases in use.") - - snap_channel_map = StoreClientCLI().get_snap_channel_map(snap_name=snap_name) - existing_architectures = snap_channel_map.get_existing_architectures() - - if not snap_channel_map.channel_map: - echo.warning("This snap has no released revisions.") - else: - if architectures: - architectures = set(architectures) - for architecture in architectures.copy(): - if architecture not in existing_architectures: - echo.warning(f"No revisions for architecture {architecture!r}.") - architectures.remove(architecture) - - # If we have no revisions for any of the architectures requested, there's - # nothing to do here. - if not architectures: - return - else: - architectures = existing_architectures - - if tracks: - tracks = set(tracks) - existing_tracks = { - s.track for s in snap_channel_map.snap.channels if s.track in tracks - } - for track in tracks - existing_tracks: - echo.warning(f"No revisions in track {track!r}.") - tracks = existing_tracks - - # If we have no revisions in any of the tracks requested, there's - # nothing to do here. - if not tracks: - return - else: - tracks = None - - click.echo( - get_tabulated_channel_map( - snap_channel_map, architectures=architectures, tracks=tracks - ) - ) - - -@storecli.command("list-revisions") -@click.option( - "--arch", metavar="", help="The snap architecture to get the status for" -) -@click.argument("snap-name", metavar="") -def list_revisions(snap_name, arch): - """Get the history on the store for . - - This command has an alias of `revisions`. - - \b - Examples: - snapcraft list-revisions my-snap - snapcraft list-revisions my-snap --arch armhf - snapcraft revisions my-snap - """ - releases = StoreClientCLI().get_snap_releases(snap_name=snap_name) - - def get_channels_for_revision(revision: int) -> List[str]: - # channels: the set of channels revision was released to, active or not. - channels: Set[str] = set() - # seen_channel: applies to channels regardless of revision. - # The first channel that shows up for each architecture is to - # be marked as the active channel, all others are historic. - seen_channel: Dict[str, Set[str]] = dict() - - for release in releases.releases: - if release.architecture not in seen_channel: - seen_channel[release.architecture] = set() - - # If the revision is in this release entry and was not seen - # before it means that this channel is active and needs to - # be represented with a *. - if ( - release.revision == revision - and release.channel not in seen_channel[release.architecture] - ): - channels.add(f"{release.channel}*") - # All other releases found for a revision are inactive. - elif ( - release.revision == revision - and release.channel not in channels - and f"{release.channel}*" not in channels - ): - channels.add(release.channel) - - seen_channel[release.architecture].add(release.channel) - - return sorted(list(channels)) - - parsed_revisions = list() - for rev in releases.revisions: - if arch and arch not in rev.architectures: - continue - channels_for_revision = get_channels_for_revision(rev.revision) - if channels_for_revision: - channels = ",".join(channels_for_revision) - else: - channels = "-" - parsed_revisions.append( - ( - rev.revision, - rev.created_at, - ",".join(rev.architectures), - rev.version, - channels, - ) - ) - - tabulated_revisions = tabulate( - parsed_revisions, - numalign="left", - headers=["Rev.", "Uploaded", "Arches", "Version", "Channels"], - tablefmt="plain", - ) - - # 23 revisions + header should not need paging. - if len(parsed_revisions) < 24: - click.echo(tabulated_revisions) - else: - click.echo_via_pager(tabulated_revisions) - - -@storecli.command("list") -def list_registered(): - """List snap names registered or shared with you. - - \b - Examples: - snapcraft list - """ - snapcraft.list_registered() - - -@storecli.command("export-login") -@click.argument( - "login_file", metavar="FILE", type=click.Path(dir_okay=False, writable=True) -) -@click.option( - "--snaps", metavar="", help="Comma-separated list of snaps to limit access" -) -@click.option( - "--channels", - metavar="", - help="Comma-separated list of channels to limit access", -) -@click.option( - "--acls", metavar="", help="Comma-separated list of ACLs to limit access" -) -@click.option( - "--expires", - metavar="", - help="Date/time (in ISO 8601) when this exported login expires", -) -@click.option( - "--experimental-login", - is_flag=True, - help="*EXPERIMENTAL* Enables login through candid.", - envvar="SNAPCRAFT_EXPERIMENTAL_LOGIN", -) -def export_login( - login_file: str, - snaps: str, - channels: str, - acls: str, - expires: str, - experimental_login: bool, -): - """Save login configuration for a store account in FILE. - - This file can then be used to log in to the given account with the - specified permissions. One can also request the login to be exported to - stdout instead of a file: - - snapcraft export-login - - - For example, to limit access to the edge channel of any snap the account - can access: - - snapcraft export-login --channels=edge exported - - Or to limit access to only the edge channel of a single snap: - - snapcraft export-login --snaps=my-snap --channels=edge exported - - To limit access to a single snap, but only until 2019: - - snapcraft export-login --expires="2019-01-01T00:00:00" exported - """ - - snap_list = None - channel_list = None - acl_list = None - - if snaps: - snap_list = [] - for package in snaps.split(","): - snap_list.append({"name": package, "series": DEFAULT_SERIES}) - - if channels: - channel_list = channels.split(",") - - if acls: - acl_list = acls.split(",") - - store_client = storeapi.StoreClient(use_candid=experimental_login) - if store_client.use_candid: - store_client.login( - packages=snap_list, - channels=channel_list, - acls=acl_list, - expires=expires, - save=False, - ) - else: - snapcraft.login( - store=store_client, - packages=snap_list, - channels=channel_list, - acls=acl_list, - expires=expires, - save=False, - ) - - # Support a login_file of '-', which indicates a desire to print to stdout - if login_file.strip() == "-": - echo.info("\nExported login starts on next line:") - store_client.export_login(config_fd=sys.stdout, encode=True) - print() - - preamble = "Login successfully exported and printed above" - login_action = 'echo "" | snapcraft login --with -' - else: - # This is sensitive-- it should only be accessible by the owner - private_open = functools.partial(os.open, mode=0o600) - - # mypy doesn't have the opener arg in its stub. Ignore its warning - with open(login_file, "w", opener=private_open) as f: # type: ignore - store_client.export_login(config_fd=f) - - # Now that the file has been written, we can just make it - # owner-readable - os.chmod(login_file, stat.S_IRUSR) - - preamble = "Login successfully exported to {0!r}".format(login_file) - login_action = "snapcraft login --with {0}".format(login_file) - - print() - echo.info( - dedent( - """\ - {}. This can now be used with - - {} - - """.format( - preamble, login_action - ) - ) - ) - try: - human_acls = _human_readable_acls(store_client) - echo.info( - "to log in to this account with no password and have these " - f"capabilities:\n{human_acls}" - ) - except NotImplementedError: - pass - - echo.warning( - "This exported login is not encrypted. Do not commit it to version control!" - ) - - -@storecli.command() -@click.option( - "--with", - "login_file", - metavar="", - type=click.File("r"), - help="Path to file created with 'snapcraft export-login'", -) -@click.option( - "--experimental-login", - is_flag=True, - help="*EXPERIMENTAL* Enables login through candid.", - envvar="SNAPCRAFT_EXPERIMENTAL_LOGIN", -) -def login(login_file, experimental_login: bool): - """Login with your Ubuntu One e-mail address and password. - - If you do not have an Ubuntu One account, you can create one at - https://snapcraft.io/account - """ - store_client = storeapi.StoreClient(use_candid=experimental_login) - if store_client.use_candid: - store_client.login(config_fd=login_file, save=True) - else: - snapcraft.login(store=store_client, config_fd=login_file) - - print() - - if login_file: - try: - human_acls = _human_readable_acls(store_client) - echo.info("Login successful. You now have these capabilities:\n") - echo.info(human_acls) - except NotImplementedError: - echo.info("Login successful.") - else: - echo.info("Login successful.") - - -@storecli.command() -def logout(): - """Clear session credentials.""" - store = storeapi.StoreClient() - store.logout() - echo.info("Credentials cleared.") - - -@storecli.command() -def whoami(): - """Returns your login information relevant to the store.""" - account = StoreClientCLI().whoami().account - - click.echo( - dedent( - f"""\ - email: {account.email} - developer-id: {account.account_id}""" - ) - ) - - -@storecli.command() -@click.argument("snap-name", metavar="") -@click.argument("track_name", metavar="") -def set_default_track(snap_name: str, track_name: str): - """Set the default track for to . - - The track must be a valid active track for this operation to be successful. - """ - store_client_cli = StoreClientCLI() - - # Client-side check to verify that the selected track exists. - snap_channel_map = store_client_cli.get_snap_channel_map(snap_name=snap_name) - active_tracks = [ - track.name - for track in snap_channel_map.snap.tracks - if track.status in ("default", "active") - ] - if track_name not in active_tracks: - echo.exit_error( - brief=f"The specified track {track_name!r} does not exist for {snap_name!r}.", - resolution=f"Ensure the {track_name!r} track exists for the {snap_name!r} snap and try again.", - details="Valid tracks for {!r}: {}.".format( - snap_name, ", ".join([f"{t!r}" for t in active_tracks]) - ), - ) - - metadata = dict(default_track=track_name) - store_client_cli.upload_metadata(snap_name=snap_name, metadata=metadata, force=True) - - echo.info(f"Default track for {snap_name!r} set to {track_name!r}.") - - -@storecli.command() -@click.argument("snap-name", metavar="") -def list_tracks(snap_name: str) -> None: - """List channel tracks for . - - This command has an alias of `tracks`. - - Track status, creation dates and version patterns are returned alongside - the track names in a space formatted table. - - Possible Status values are: - - \b - - active, visible tracks available for installation - - default, the default track to install from when not explicit - - hidden, tracks available for installation but unlisted - - closed, tracks that are no longer available to install from - - A version pattern is a regular expression that restricts a snap revision - from being released to a track if the version string set does not match. - """ - store_client_cli = StoreClientCLI() - snap_channel_map = store_client_cli.get_snap_channel_map(snap_name=snap_name) - - # Iterate over the entries, replace None with - for consistent presentation - track_table: List[List[str]] = [ - [ - track.name, - track.status, - track.creation_date if track.creation_date else "-", - track.version_pattern if track.version_pattern else "-", - ] - for track in snap_channel_map.snap.tracks - ] - - click.echo( - tabulate( - # Sort by "creation-date". - sorted(track_table, key=operator.itemgetter(2)), - headers=["Name", "Status", "Creation-Date", "Version-Pattern"], - tablefmt="plain", - ) - ) - - -_YESTERDAY = str(date.today() - timedelta(days=1)) - - -@storecli.command() -@click.argument("snap-name", metavar="", required=True) -@click.option( - "--name", - metavar="", - help="Metric name", - type=click.Choice([x.value for x in metrics_module.MetricsNames]), - required=True, -) -@click.option( - "--start", - metavar="", - help="Date in format YYYY-MM-DD", - required=True, - default=_YESTERDAY, -) -@click.option( - "--end", - metavar="", - help="Date in format YYYY-MM-DD", - required=True, - default=_YESTERDAY, -) -@click.option( - "--format", - metavar="", - help="Format for output", - type=click.Choice(["table", "json"]), - required=True, -) -def metrics(snap_name: str, name: str, start: str, end: str, format: str): - """Get metrics for .""" - store = storeapi.StoreClient() - account_info = store.get_account_information() - - try: - snap_id = account_info["snaps"][DEFAULT_SERIES][snap_name]["snap-id"] - except KeyError: - echo.exit_error( - brief="No permissions for snap.", - resolution="Ensure the snap name and credentials are correct.is correct and that the correct credentials are used.", - ) - - mf = metrics_module.MetricsFilter( - snap_id=snap_id, metric_name=name, start=start, end=end - ) - - results = store.get_metrics(filters=[mf], snap_name=snap_name) - - # Sanity check to ensure that only one result is found (as we currently only - # support one query at a time). - if len(results.metrics) != 1: - raise RuntimeError(f"Unexpected metric results from store: {results!r}") - - metric_results = results.metrics[0] - - if format == "json": - output = json.dumps(metric_results.marshal(), indent=2, sort_keys=True) - click.echo(output) - elif format == "table": - rows = convert_metrics_to_table(metric_results, transpose=True) - output = tabulate(rows, tablefmt="plain") - echo.echo_with_pager_if_needed(output) diff --git a/snapcraft/commands/__init__.py b/snapcraft/commands/__init__.py new file mode 100644 index 0000000000..7f01129761 --- /dev/null +++ b/snapcraft/commands/__init__.py @@ -0,0 +1,106 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft commands.""" + +from .account import ( + StoreExportLoginCommand, + StoreLoginCommand, + StoreLogoutCommand, + StoreWhoAmICommand, +) +from .extensions import ( + ExpandExtensionsCommand, + ExtensionsCommand, + ListExtensionsCommand, +) +from .legacy import ( + StoreLegacyCreateKeyCommand, + StoreLegacyEditValidationSetsCommand, + StoreLegacyGatedCommand, + StoreLegacyListKeysCommand, + StoreLegacyListRevisionsCommand, + StoreLegacyListValidationSetsCommand, + StoreLegacyMetricsCommand, + StoreLegacyPromoteCommand, + StoreLegacyRegisterKeyCommand, + StoreLegacyRemoteBuildCommand, + StoreLegacySetDefaultTrackCommand, + StoreLegacySignBuildCommand, + StoreLegacyUploadMetadataCommand, + StoreLegacyValidateCommand, +) +from .lifecycle import ( + BuildCommand, + CleanCommand, + PackCommand, + PrimeCommand, + PullCommand, + SnapCommand, + StageCommand, +) +from .manage import StoreCloseCommand, StoreReleaseCommand +from .names import ( + StoreLegacyListCommand, + StoreLegacyListRegisteredCommand, + StoreNamesCommand, + StoreRegisterCommand, +) +from .status import StoreListTracksCommand, StoreStatusCommand, StoreTracksCommand +from .upload import StoreUploadCommand +from .version import VersionCommand + +__all__ = [ + "BuildCommand", + "CleanCommand", + "ExpandExtensionsCommand", + "ExtensionsCommand", + "ListExtensionsCommand", + "PackCommand", + "PrimeCommand", + "PullCommand", + "SnapCommand", + "StageCommand", + "StoreCloseCommand", + "StoreExportLoginCommand", + "StoreLegacyCreateKeyCommand", + "StoreLegacyEditValidationSetsCommand", + "StoreLegacyGatedCommand", + "StoreLegacyListCommand", + "StoreLegacyListRegisteredCommand", + "StoreLegacyListRevisionsCommand", + "StoreLegacyListValidationSetsCommand", + "StoreLegacyMetricsCommand", + "StoreLegacyPromoteCommand", + "StoreLegacyRegisterKeyCommand", + "StoreLegacyRemoteBuildCommand", + "StoreLegacySetDefaultTrackCommand", + "StoreLegacySignBuildCommand", + "StoreLegacyUploadMetadataCommand", + "StoreLegacyValidateCommand", + "StoreLegacyListKeysCommand", + "StoreListTracksCommand", + "StoreLoginCommand", + "StoreLogoutCommand", + "StoreNamesCommand", + "StoreRegisterCommand", + "StoreReleaseCommand", + "StoreStatusCommand", + "StoreTracksCommand", + "StoreUploadCommand", + "StoreWhoAmICommand", + "VersionCommand", +] diff --git a/snapcraft/commands/account.py b/snapcraft/commands/account.py new file mode 100644 index 0000000000..1a9418f23d --- /dev/null +++ b/snapcraft/commands/account.py @@ -0,0 +1,277 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Store Account management commands.""" + +import contextlib +import functools +import os +import stat +import textwrap +from datetime import datetime +from typing import TYPE_CHECKING, Dict, Union + +from craft_cli import BaseCommand, emit +from craft_cli.errors import ArgumentParsingError +from overrides import overrides + +from snapcraft import utils + +from . import store + +if TYPE_CHECKING: + import argparse + + +_VALID_DATE_FORMATS = [ + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%SZ", +] + + +class StoreLoginCommand(BaseCommand): + """Command to login to the Snap Store.""" + + name = "login" + help_msg = "Login to the Snap Store" + overview = textwrap.dedent( + f""" + Login to the Snap Store with your Ubuntu One SSO credentials. + If you do not have any, you can create them on https://login.ubuntu.com + + To use the alternative authentication mechanism (Candid), set the + environment variable {store.constants.ENVIRONMENT_STORE_AUTH!r} to 'candid'. + + The login command requires a working keyring on the system it is used on. + As an alternative to login in one can export + {store.constants.ENVIRONMENT_STORE_CREDENTIALS!r} with the exported credentials. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + """Add arguments specific to the export-login command.""" + parser.add_argument( + "--with", + metavar="", + dest="login_with", + type=str, + nargs=1, + default=None, + help="File to use for imported credentials", + ) + parser.add_argument( + "--experimental-login", + action="store_true", + default=False, + help=( + "Deprecated option to enable candid login. " + f"Set {store.constants.ENVIRONMENT_STORE_AUTH}=candid instead" + ), + ) + + @overrides + def run(self, parsed_args): + if parsed_args.experimental_login: + raise ArgumentParsingError( + "--experimental-login no longer supported. " + f"Set {store.constants.ENVIRONMENT_STORE_AUTH}=candid instead", + ) + + if parsed_args.login_with: + raise ArgumentParsingError( + "--with is no longer supported, export the auth to the environment " + f"variable {store.constants.ENVIRONMENT_STORE_CREDENTIALS!r} instead", + ) + + store.StoreClientCLI().login() + emit.message("Login successful") + + +class StoreExportLoginCommand(BaseCommand): + """Command to export login to use with the Snap Store.""" + + name = "export-login" + help_msg = "Login to the Snap Store exporting the credentials" + overview = textwrap.dedent( + f""" + Login to the Snap Store with your Ubuntu One SSO credentials. + If you do not have any, you can create them on https://login.ubuntu.com + + To use the alternative authentication mechanism (Candid), set the + environment variable {store.constants.ENVIRONMENT_STORE_AUTH!r} to 'candid'. + + This command exports credentials to use on systems where login is not + possible or desired. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + """Add arguments specific to the export-login command.""" + parser.add_argument( + "login_file", + metavar="", + type=str, + help="Where to write the exported credentials, - for stdout", + ) + parser.add_argument( + "--snaps", + metavar="", + type=str, + nargs="?", + default=None, + help="Comma-separated list of snaps to limit access", + ) + parser.add_argument( + "--channels", + metavar="", + type=str, + nargs="?", + default=None, + help="Comma-separated list of channels to limit access", + ) + parser.add_argument( + "--acls", + metavar="", + type=str, + nargs="?", + default=None, + help="Comma-separated list of ACLs to limit access", + ) + parser.add_argument( + "--expires", + metavar="", + type=str, + nargs="?", + default=None, + help="Date/time (in ISO 8601) when this exported login expires", + ) + parser.add_argument( + "--experimental-login", + action="store_true", + default=False, + help=( + "Deprecated option to enable candid login. " + f"Set {store.constants.ENVIRONMENT_STORE_AUTH}=candid instead" + ), + ) + + @overrides + def run(self, parsed_args): + if parsed_args.experimental_login: + raise ArgumentParsingError( + "--experimental-login no longer supported. " + f"Set {store.constants.ENVIRONMENT_STORE_AUTH}=candid instead", + ) + + kwargs: Dict[str, Union[str, int]] = {} + if parsed_args.snaps: + kwargs["packages"] = parsed_args.snaps.split(",") + if parsed_args.channels: + kwargs["channels"] = parsed_args.channels.split(",") + if parsed_args.acls: + kwargs["acls"] = parsed_args.acls.split(",") + if parsed_args.expires is not None: + for date_format in _VALID_DATE_FORMATS: + with contextlib.suppress(ValueError): + expiry_date = datetime.strptime(parsed_args.expires, date_format) + break + else: + valid_formats = utils.humanize_list(_VALID_DATE_FORMATS, "or") + raise ArgumentParsingError( + f"The expiry follow an ISO 8601 format ({valid_formats})" + ) + + kwargs["ttl"] = int((expiry_date - datetime.now()).total_seconds()) + + credentials = store.StoreClientCLI(ephemeral=True).login(**kwargs) + + # Support a login_file of '-', which indicates a desire to print to stdout + if parsed_args.login_file.strip() == "-": + message = f"Exported login credentials:\n{credentials}" + else: + # This is sensitive-- it should only be accessible by the owner + private_open = functools.partial(os.open, mode=0o600) + + with open( + parsed_args.login_file, "w", opener=private_open, encoding="utf-8" + ) as login_fd: + print(credentials, file=login_fd, end="") + + # Now that the file has been written, we can just make it + # owner-readable + os.chmod(parsed_args.login_file, stat.S_IRUSR) + + message = f"Exported login credentials to {parsed_args.login_file!r}" + + emit.message(message) + + +class StoreWhoAmICommand(BaseCommand): + """Command to show login information from Snap Store.""" + + name = "whoami" + help_msg = "Get information about the current login" + overview = textwrap.dedent( + """ + Return useful information about the current login. + """ + ) + + @overrides + def run(self, parsed_args): + whoami = store.StoreClientCLI().store_client.whoami() + + if whoami.get("permissions"): + permissions = ", ".join(whoami["permissions"]) + else: + permissions = "no restrictions" + + if whoami.get("channels"): + channels = ", ".join(whoami["channels"]) + else: + channels = "no restrictions" + + account = whoami["account"] + message = textwrap.dedent( + f"""\ + email: {account["email"]} + username: {account["username"]} + id: {account["id"]} + permissions: {permissions} + channels: {channels} + expires: {whoami["expires"]}Z""" + ) + + emit.message(message) + + +class StoreLogoutCommand(BaseCommand): + """Command to logout from the Snap Store.""" + + name = "logout" + help_msg = "Clear Snap Store credentials." + overview = textwrap.dedent( + """ + Remove stored credentials Snap Store credentials from the system. + """ + ) + + @overrides + def run(self, parsed_args): + store.StoreClientCLI().store_client.logout() + emit.message("Credentials cleared") diff --git a/snapcraft/commands/extensions.py b/snapcraft/commands/extensions.py new file mode 100644 index 0000000000..6c00e2cccb --- /dev/null +++ b/snapcraft/commands/extensions.py @@ -0,0 +1,117 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft lifecycle commands.""" + +import abc +import textwrap +from typing import Dict, List + +import tabulate +import yaml +from craft_cli import BaseCommand, emit +from overrides import overrides +from pydantic import BaseModel + +from snapcraft import extensions +from snapcraft.parts.lifecycle import get_snap_project, process_yaml +from snapcraft_legacy.internal.project_loader import ( + find_extension, + supported_extension_names, +) + + +class ExtensionModel(BaseModel): + """Extension model for presentation.""" + + name: str + bases: List[str] + + def marshal(self) -> Dict[str, str]: + """Marshal model into a dictionary for presentation.""" + return { + "Extension name": self.name, + "Supported bases": ", ".join(sorted(self.bases)), + } + + +class ListExtensionsCommand(BaseCommand, abc.ABC): + """A command to list the available extensions.""" + + name = "list-extensions" + help_msg = "List available extensions" + overview = textwrap.dedent( + """ + List the available extensions and the bases it can work on. + """ + ) + + @overrides + def run(self, parsed_args): + extension_presentation: Dict[str, ExtensionModel] = {} + + # New extensions. + for extension_name in extensions.registry.get_extension_names(): + extension_class = extensions.registry.get_extension_class(extension_name) + extension_bases = list(extension_class.get_supported_bases()) + extension_presentation[extension_name] = ExtensionModel( + name=extension_name, bases=extension_bases + ) + + # Extensions from snapcraft_legacy. + for extension_name in supported_extension_names(): + extension_class = find_extension(extension_name) + extension_name = extension_name.replace("_", "-") + extension_bases = list(extension_class.get_supported_bases()) + if extension_name in extension_presentation: + extension_presentation[extension_name].bases += extension_bases + else: + extension_presentation[extension_name] = ExtensionModel( + name=extension_name, bases=extension_bases + ) + + printable_extensions = sorted( + [v.marshal() for v in extension_presentation.values()], + key=lambda d: d["Extension name"], + ) + emit.message(tabulate.tabulate(printable_extensions, headers="keys")) + + +class ExtensionsCommand(ListExtensionsCommand, abc.ABC): + """A command alias to list the available extensions.""" + + name = "extensions" + hidden = True + + +class ExpandExtensionsCommand(BaseCommand, abc.ABC): + """A command to expand the yaml from extensions.""" + + name = "expand-extensions" + help_msg = "Expand extensions in snapcraft.yaml" + overview = textwrap.dedent( + """ + Extensions defined under apps in snapcraft.yaml will be + expanded and shown as output. + """ + ) + + @overrides + def run(self, parsed_args): + snap_project = get_snap_project() + yaml_data = process_yaml(snap_project.project_file) + + emit.message(yaml.safe_dump(yaml_data, indent=4, sort_keys=False)) diff --git a/snapcraft/commands/legacy.py b/snapcraft/commands/legacy.py new file mode 100644 index 0000000000..da522b808b --- /dev/null +++ b/snapcraft/commands/legacy.py @@ -0,0 +1,401 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Commands that call to the legacy implementation.""" + +import textwrap +from typing import TYPE_CHECKING + +from craft_cli import BaseCommand +from overrides import overrides + +from snapcraft_legacy.cli import legacy + +if TYPE_CHECKING: + import argparse + + +class LegacyBaseCommand(BaseCommand): + """Legacy command runner.""" + + @overrides + def run(self, parsed_args): + legacy.legacy_run() + + +######### +# Store # +######### + + +class StoreLegacyUploadMetadataCommand(LegacyBaseCommand): + """Command passthrough for the upload-metadata command.""" + + name = "upload-metadata" + help_msg = "Upload metadata from to the store" + overview = textwrap.dedent( + """ + The following information will be retrieved from and used to + update the store: + + - summary + - description + - icon + + If --force is given, it will force the local metadata into the Store, + ignoring any possible conflict. + + Examples: + snapcraft upload-metadata my-snap_0.1_amd64.snap + snapcraft upload-metadata my-snap_0.1_amd64.snap --force + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "--force", + action="store_true", + default=False, + help="Force metadata update to override any possible conflict", + ) + + +class StoreLegacyPromoteCommand(LegacyBaseCommand): + """Command passthrough for the promote command.""" + + name = "promote" + help_msg = "Promote a build set from a channel" + overview = textwrap.dedent( + """ + A build set is a set of commonly tagged revisions, the most simple + form of a build set is a set of revisions released to a channel. + + Currently, only channels are supported to release from () + + Prior to releasing, visual confirmation shall be required. + + The format for channels is `[/][/]` where + + - is used to have long term release channels. It is implicitly + set to the default. + - is mandatory and can be either `stable`, `candidate`, `beta` + or `edge`. + - is optional and dynamically creates a channel with a + specific expiration date. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "--from-channel", + metavar="from-channel", + help="the channel to promote from", + required=True, + ) + parser.add_argument( + "--to-channel", + metavar="to-channel", + help="the channel to promote to", + required=True, + ) + parser.add_argument( + "--yes", action="store_true", help="do not prompt for confirmation" + ) + + +class StoreLegacyListRevisionsCommand(LegacyBaseCommand): + """Command passthrough for the list-revisions command.""" + + name = "list-revisions" + help_msg = "List published revisions for " + overview = textwrap.dedent( + """ + Examples: + snapcraft list-revisions my-snap + snapcraft list-revisions my-snap --arch armhf + snapcraft revisions my-snap + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "snap_name", + metavar="snap-name", + ) + parser.add_argument( + "--arch", + metavar="arch", + help="architecture filter", + ) + + +class StoreLegacySetDefaultTrackCommand(LegacyBaseCommand): + """Command passthrough for the set-default-track command.""" + + name = "set-default-track" + help_msg = "Set the default track for a snap" + overview = textwrap.dedent( + """ + Set the default track for to . must already exist.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "snap_name", + metavar="snap-name", + ) + parser.add_argument( + "track", + ) + + +class StoreLegacyMetricsCommand(LegacyBaseCommand): + """Command passthrough for the metrics command.""" + + name = "metrics" + help_msg = "Get metrics for a snap" + overview = textwrap.dedent( + """ + Get different metrics from the Snap Store for a given snap.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument("snap_name", metavar="snap-name") + parser.add_argument("--name", metavar="name", required=True, help="metric name") + parser.add_argument( + "--start", + metavar="start-date", + help="date in format YYYY-MM-DD", + ) + parser.add_argument( + "--end", + metavar="end-date", + help="date in format YYYY-MM-DD", + ) + parser.add_argument( + "--format", + metavar="format", + help="format for output", + choices=["table", "json"], + required=True, + ) + + +######### +# Build # +######### + + +class StoreLegacyRemoteBuildCommand(LegacyBaseCommand): + """Command passthrough for the remote-build command.""" + + name = "remote-build" + help_msg = "Dispatch a snap for remote build" + overview = textwrap.dedent( + """ + Command remote-build sends the current project to be built remotely. After the build + is complete, packages for each architecture are retrieved and will be available in + the local filesystem. + + If not specified in the snapcraft.yaml file, the list of architectures to build + can be set using the --build-on option. If both are specified, an error will occur. + + Interrupted remote builds can be resumed using the --recover option, followed by + the build number informed when the remote build was originally dispatched. The + current state of the remote build for each architecture can be checked using the + --status option.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "--recover", action="store_true", help="recover an interrupted build" + ) + parser.add_argument( + "--status", action="store_true", help="display remote build status" + ) + parser.add_argument( + "--build-on", + metavar="arch", + nargs="+", + help="architecture to build on", + ) + parser.add_argument( + "--build-id", metavar="build-id", help="specific build id to retrieve" + ) + parser.add_argument( + "--launchpad-accept-public-upload", + action="store_true", + help="acknowledge that uploaded code will be publicly available.", + ) + + +############## +# Assertions # +############## + + +class StoreLegacyListKeysCommand(LegacyBaseCommand): + """Command passthrough for the list-keys command.""" + + name = "list-keys" + help_msg = "List the keys available to sign assertions" + overview = textwrap.dedent( + """ + List the available keys to sign assertions together with they + local availability.""" + ) + + +class StoreLegacyCreateKeyCommand(LegacyBaseCommand): + """Command passthrough for the create-key command.""" + + name = "create-key" + help_msg = "Create a key to sign assertions." + overview = textwrap.dedent( + """ + Create a key and store it locally. Use the register-key command to register + it on the store.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "key_name", metavar="key-name", help="Key used to sign the assertion" + ) + + +class StoreLegacyRegisterKeyCommand(LegacyBaseCommand): + """Command passthrough for the register-key command.""" + + name = "register-key" + help_msg = "Register a key to sign assertions with the Snap Store." + overview = textwrap.dedent( + """ + Register a a key with the Snap Store. Prior to registration, use register-key + to create one.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "key_name", metavar="key-name", help="Key used to sign the assertion" + ) + + +class StoreLegacySignBuildCommand(LegacyBaseCommand): + """Command passthrough for the sign-build command.""" + + name = "sign-build" + help_msg = "Sign a built snap file and assert it using the developer's key" + overview = textwrap.dedent( + """ + Sign a specific build of a snap with a given key and upload the assertion + to the Snap Store (unless --local).""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "--key-name", metavar="key-name", help="key used to sign the assertion" + ) + parser.add_argument( + "--local", + "--local", + action="store_true", + help="do not aupload to the Snap Store", + ) + + +class StoreLegacyValidateCommand(LegacyBaseCommand): + """Command passthrough for the validate command.""" + + name = "validate" + help_msg = "Validate a gated snap" + overview = textwrap.dedent( + """ + Each validation can be presented with either syntax: + + - = + - =""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "--key-name", metavar="key-name", help="key used to sign the assertion" + ) + parser.add_argument("--revoke", action="store_true", help="revoke validations") + parser.add_argument("snap_name", metavar="snap-name") + parser.add_argument("validations", nargs="+") + + +class StoreLegacyGatedCommand(LegacyBaseCommand): + """Command passthrough for the gated command.""" + + name = "gated" + help_msg = "List all gated snaps for " + overview = textwrap.dedent( + """ + Get the list of snaps and revisions gating a snaps""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument("snap_name", metavar="snap-name") + + +class StoreLegacyListValidationSetsCommand(LegacyBaseCommand): + """Command passthrough for the edit-validation-sets command.""" + + name = "list-validation-sets" + help_msg = "Get the list of validation sets" + overview = textwrap.dedent( + """ + List all list-validation-sets snaps. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument("snap_name", metavar="snap-name") + + +class StoreLegacyEditValidationSetsCommand(LegacyBaseCommand): + """Command passthrough for the edit-validation-sets command.""" + + name = "edit-validation-sets" + help_msg = "Edit the list of validations for " + overview = textwrap.dedent( + """ + Refer to https://snapcraft.io/docs/validation-sets for further information + on Validation Sets. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "--key-name", metavar="key-name", help="Key used to sign the assertion" + ) + parser.add_argument("account_id", metavar="account-id") + parser.add_argument("set_name", metavar="set-name") + parser.add_argument("sequence", metavar="sequence") diff --git a/snapcraft/commands/lifecycle.py b/snapcraft/commands/lifecycle.py new file mode 100644 index 0000000000..54de39e1f9 --- /dev/null +++ b/snapcraft/commands/lifecycle.py @@ -0,0 +1,245 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft lifecycle commands.""" + +import abc +import argparse +import textwrap + +from craft_cli import BaseCommand, emit +from overrides import overrides + +from snapcraft import pack +from snapcraft.parts import lifecycle as parts_lifecycle + + +class _LifecycleCommand(BaseCommand, abc.ABC): + """Run lifecycle-related commands.""" + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--destructive-mode", + action="store_true", + help="Build in the current host", + ) + group.add_argument( + "--use-lxd", + action="store_true", + help="Use LXD to build", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Shell into the environment if the build fails", + ) + + # --enable-experimental-extensions is only available in legacy + parser.add_argument( + "--enable-experimental-extensions", + action="store_true", + help=argparse.SUPPRESS, + ) + # --enable-developer-debug is only available in legacy + parser.add_argument( + "--enable-developer-debug", + action="store_true", + help=argparse.SUPPRESS, + ) + # --enable-experimental-target-arch is only available in legacy + parser.add_argument( + "--enable-experimental-target-arch", + action="store_true", + help=argparse.SUPPRESS, + ) + # --target-arch is only available in legacy + parser.add_argument("--target-arch", help=argparse.SUPPRESS) + # --provider is only available in legacy + parser.add_argument("--provider", help=argparse.SUPPRESS) + + @overrides + def run(self, parsed_args): + """Run the command.""" + if not self.name: + raise RuntimeError("command name not specified") + + emit.trace(f"lifecycle command: {self.name!r}, arguments: {parsed_args!r}") + parts_lifecycle.run(self.name, parsed_args) + + +class _LifecycleStepCommand(_LifecycleCommand): + """Run lifecycle step commands.""" + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + super().fill_parser(parser) + parser.add_argument( + "parts", + metavar="part-name", + type=str, + nargs="*", + help="Optional list of parts to process", + ) + + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--shell", + action="store_true", + help="Shell into the environment in lieu of the step to run.", + ) + group.add_argument( + "--shell-after", + action="store_true", + help="Shell into the environment after the step has run.", + ) + + +class PullCommand(_LifecycleStepCommand): + """Run the lifecycle up to the pull step.""" + + name = "pull" + help_msg = "Download or retrieve artifacts defined for a part" + overview = textwrap.dedent( + """ + Download or retrieve artifacts defined for a part. If part names + are specified only those parts will be pulled, otherwise all parts + will be pulled. + """ + ) + + +class BuildCommand(_LifecycleStepCommand): + """Run the lifecycle up to the build step.""" + + name = "build" + help_msg = "Build artifacts defined for a part" + overview = textwrap.dedent( + """ + Build artifacts defined for a part. If part names are specified only + those parts will be built, otherwise all parts will be built. + """ + ) + + +class StageCommand(_LifecycleStepCommand): + """Run the lifecycle up to the stage step.""" + + name = "stage" + help_msg = "Stage built artifacts into a common staging area" + overview = textwrap.dedent( + """ + Stage built artifacts into a common staging area. If part names are + specified only those parts will be staged. The default is to stage + all parts. + """ + ) + + +class PrimeCommand(_LifecycleStepCommand): + """Prepare the final payload for packing.""" + + name = "prime" + help_msg = "Prime artifacts defined for a part" + overview = textwrap.dedent( + """ + Prepare the final payload to be packed as a snap, performing additional + processing and adding metadata files. If part names are specified only + those parts will be primed. The default is to prime all parts. + """ + ) + + +class PackCommand(_LifecycleCommand): + """Pack the final snap payload.""" + + name = "pack" + help_msg = "Create the snap package" + overview = textwrap.dedent( + """ + Process parts and create a snap file containing the project payload + with the provided metadata. If a directory is specified, pack its + contents instead. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + """Add arguments specific to the pack command.""" + super().fill_parser(parser) + parser.add_argument( + "directory", + metavar="directory", + type=str, + nargs="?", + default=None, + help="Directory to pack", + ) + parser.add_argument( + "-o", + "--output", + metavar="filename", + type=str, + help="Path to the resulting snap", + ) + + @overrides + def run(self, parsed_args): + """Run the command.""" + if parsed_args.directory: + pack.pack_snap(parsed_args.directory, output=parsed_args.output) + else: + super().run(parsed_args) + + +class SnapCommand(_LifecycleCommand): + """Pack the final snap payload. This is a legacy compatibility command.""" + + name = "snap" + help_msg = "Create a snap" + hidden = True + overview = textwrap.dedent( + """ + Process parts and create a snap file containing the project payload + with the provided metadata. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + """Add arguments specific to the pack command.""" + super().fill_parser(parser) + parser.add_argument( + "-o", + "--output", + metavar="filename", + type=str, + help="Path to the resulting snap", + ) + + +class CleanCommand(_LifecycleStepCommand): + """Remove a part's assets.""" + + name = "clean" + help_msg = "Remove a part's assets" + overview = textwrap.dedent( + """ + Clean up artifacts belonging to parts. If no parts are specified, + remove the managed snap packing environment (VM or container). + """ + ) diff --git a/snapcraft/commands/manage.py b/snapcraft/commands/manage.py new file mode 100644 index 0000000000..8e29f57ae4 --- /dev/null +++ b/snapcraft/commands/manage.py @@ -0,0 +1,165 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Store Account management commands.""" + +import textwrap +from typing import TYPE_CHECKING + +from craft_cli import BaseCommand, emit +from overrides import overrides + +from snapcraft import errors, utils + +from . import store + +if TYPE_CHECKING: + import argparse + + +class StoreReleaseCommand(BaseCommand): + """Command to release a snap on the Snap Store.""" + + name = "release" + help_msg = "Release to the store" + overview = textwrap.dedent( + """ + Release on to the selected store . + is a comma separated list of valid channels on the store. + + The must exist on the store, to see available revisions run + `snapcraft list-revisions `. + + The channel map will be displayed after the operation takes place. To see + the status map at any other time run `snapcraft status `. + + The format for channels is `[/][/]` where + + - is used to have long term release channels. It is implicitly + set to `latest`. If this snap requires one, it can be created by + request by having a conversation on https://forum.snapcraft.io + under the *store* category. + - is mandatory and can be either `stable`, `candidate`, `beta` + or `edge`. + - is optional and dynamically creates a channel with a + specific expiration date. + + Examples: + snapcraft release my-snap 8 stable + snapcraft release my-snap 8 stable/my-branch + snapcraft release my-snap 9 beta,edge + snapcraft release my-snap 9 lts-channel/stable + snapcraft release my-snap 9 lts-channel/stable/my-branch""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "name", + type=str, + help="The snap name to release", + ) + parser.add_argument( + "revision", + type=int, + help="The revision to release", + ) + parser.add_argument( + "channels", + type=str, + help="The comma separated list of channels to release to", + ) + parser.add_argument( + "--progressive", + dest="progressive_percentage", + type=int, + default=None, + help="set a release progression to a certain percentage [0<=x<=100]", + ) + + @overrides + def run(self, parsed_args): + channels = parsed_args.channels.split(",") + + store.StoreClientCLI().release( + snap_name=parsed_args.name, + revision=parsed_args.revision, + channels=channels, + progressive_percentage=parsed_args.progressive_percentage, + ) + + humanized_channels = utils.humanize_list(channels, conjunction="and") + emit.message( + f"Released {parsed_args.name!r} " + f"revision {parsed_args.revision!r} " + f"to channels: {humanized_channels}" + ) + + +class StoreCloseCommand(BaseCommand): + """Command to close a channel for a snap on the Snap Store.""" + + name = "close" + help_msg = "Close for on the store" + overview = textwrap.dedent( + """ + Closing a channel allows the that is closed to track the + channel that follows it in the channel release chain. + As such closing the 'candidate' channel would make it track the + 'stable' channel. + + Examples: + snapcraft close my-snap --channel beta + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "name", + type=str, + help="The snap name to release", + ) + parser.add_argument( + "channel", + type=str, + help="The channel to close", + ) + + @overrides + def run(self, parsed_args): + client = store.StoreClientCLI() + + # Account info request to retrieve the snap-id + account_info = client.get_account_info() + try: + snap_id = account_info["snaps"][store.constants.DEFAULT_SERIES][ + parsed_args.name + ]["snap-id"] + except KeyError as key_error: + emit.trace(f"{key_error!r} no found in {account_info!r}") + raise errors.SnapcraftError( + f"{parsed_args.name!r} not found or not owned by this account" + ) from key_error + + client.close( + snap_id=snap_id, + channel=parsed_args.channel, + ) + + emit.message( + f"Channel {parsed_args.channel!r} for {parsed_args.name!r} is now closed" + ) diff --git a/snapcraft/commands/names.py b/snapcraft/commands/names.py new file mode 100644 index 0000000000..a46ee31c56 --- /dev/null +++ b/snapcraft/commands/names.py @@ -0,0 +1,183 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Store Account management commands.""" + +import operator +import textwrap +from typing import TYPE_CHECKING + +from craft_cli import BaseCommand, emit +from overrides import overrides +from tabulate import tabulate + +from snapcraft import utils + +from . import store + +if TYPE_CHECKING: + import argparse + + +_MESSAGE_REGISTER_PRIVATE = textwrap.dedent( + """\ + Even though this is private snap, you should think carefully about + the choice of name and make sure you are confident nobody else will + have a stronger claim to that particular name. If you are unsure + then we suggest you prefix the name with your developer identity, + As '$username-yoyodyne-www-site-content'.""" +) +_MESSAGE_REGISTER_CONFIRM = textwrap.dedent( + """\ + We always want to ensure that users get the software they expect + for a particular name. + + If needed, we will rename snaps to ensure that a particular name + reflects the software most widely expected by our community. + + For example, most people would expect 'thunderbird' to be published by + Mozilla. They would also expect to be able to get other snaps of + Thunderbird as '$username-thunderbird'. + + Would you say that MOST users will expect {!r} to come from + you, and be the software you intend to publish there?""" +) +_MESSAGE_REGISTER_SUCCESS = "Registered {!r}" +_MESSAGE_REGISTER_NO = "Snap name {!r} not registered" + + +class StoreRegisterCommand(BaseCommand): + """Command to register a snap with the Snap Store.""" + + name = "register" + help_msg = "Register with the store" + overview = textwrap.dedent( + """ + You can use this command to register an available and become the + publisher for this snap.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "snap-name", + type=str, + help="The snap name to register", + ) + parser.add_argument( + "--store", + metavar="", + dest="store_id", + type=str, + default=None, + help="Store to register with", + ) + parser.add_argument( + "--private", + action="store_true", + default=False, + help="Register the snap as a private one", + ) + parser.add_argument( + "--yes", + action="store_true", + default=False, + help="Do not ask for confirmation", + ) + + @overrides + def run(self, parsed_args): + # dest does not work when filling the parser so getattr instead + snap_name = getattr(parsed_args, "snap-name") + + if parsed_args.private: + emit.message( + _MESSAGE_REGISTER_PRIVATE.format(snap_name), + intermediate=True, + ) + if parsed_args.yes or utils.confirm_with_user( + _MESSAGE_REGISTER_CONFIRM.format(snap_name) + ): + store.StoreClientCLI().register( + snap_name, is_private=parsed_args.private, store_id=parsed_args.store_id + ) + emit.message(_MESSAGE_REGISTER_SUCCESS.format(snap_name)) + else: + emit.message(_MESSAGE_REGISTER_NO.format(snap_name)) + + +class StoreNamesCommand(BaseCommand): + """Command to list the snap names registered with the current account.""" + + name = "names" + help_msg = "List the names registered to the logged it account" + overview = textwrap.dedent( + """ + Return the list of snap names together with the registration date, + its visibility and any additional notes.""" + ) + + @overrides + def run(self, parsed_args): + account_info = store.StoreClientCLI().get_account_info() + + snaps = [ + ( + name, + info["since"], + "private" if info["private"] else "public", + "-", + ) + for name, info in account_info["snaps"] + .get(store.constants.DEFAULT_SERIES, {}) + .items() + # Presenting only approved snap registrations, which means name + # disputes will be displayed/sorted some other way. + if info["status"] == "Approved" + ] + if not snaps: + emit.message("No registered snaps") + else: + tabulated_snaps = tabulate( + sorted(snaps, key=operator.itemgetter(0)), + headers=["Name", "Since", "Visibility", "Notes"], + tablefmt="plain", + ) + emit.message(tabulated_snaps) + + +class StoreLegacyListCommand(StoreNamesCommand): + """Legacy command to list the snap names registered with the current account.""" + + name = "list" + hidden = True + + @overrides + def run(self, parsed_args): + emit.progress("This command is deprecated: use 'names' instead") + super().run(parsed_args) + + +class StoreLegacyListRegisteredCommand(StoreNamesCommand): + """Legacy command to list the snap names registered with the current account.""" + + name = "list-registered" + hidden = True + + @overrides + def run(self, parsed_args): + emit.progress("This command is deprecated: use 'names' instead") + super().run(parsed_args) diff --git a/snapcraft/commands/status.py b/snapcraft/commands/status.py new file mode 100644 index 0000000000..17f6ad06cd --- /dev/null +++ b/snapcraft/commands/status.py @@ -0,0 +1,424 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Store Account management commands.""" +import itertools +import operator +import textwrap +from collections import OrderedDict +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, cast + +from craft_cli import BaseCommand, emit +from overrides import overrides +from tabulate import tabulate +from typing_extensions import Final + +from snapcraft.commands import store +from snapcraft.commands.store.channel_map import ( + ChannelMap, + MappedChannel, + Revision, + SnapChannel, +) + +if TYPE_CHECKING: + import argparse + + +class StoreStatusCommand(BaseCommand): + """Command to check the status of a snap on the Snap Store.""" + + name = "status" + help_msg = "Show the status of a snap on the Snap Store" + overview = textwrap.dedent( + """ + Show the status of a snap on the Snap Store. + The name must be accessible from the requesting account by being + the owner or a collaborator of the snap.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "name", + type=str, + help="Get the status on a snap from the Snap Store", + ) + parser.add_argument( + "--arch", + metavar="", + type=str, + nargs="?", + help="Limit the status report to the requested architectures", + ) + parser.add_argument( + "--track", + metavar="", + type=str, + nargs="?", + help="Limit the status report to the requested tracks", + ) + + @overrides + def run(self, parsed_args): + snap_channel_map = store.StoreClientCLI().get_channel_map( + snap_name=parsed_args.name + ) + + existing_architectures = snap_channel_map.get_existing_architectures() + if not snap_channel_map.channel_map: + emit.message("This snap has no released revisions") + return + + architectures = existing_architectures + if parsed_args.arch: + architectures = set(parsed_args.arch) + for architecture in architectures.copy(): + if architecture not in existing_architectures: + emit.progress(f"No revisions for architecture {architecture!r}") + architectures.remove(architecture) + + # If we have no revisions for any of the architectures requested, there's + # nothing to do here. + if not architectures: + return + + tracks: List[str] = [] + if parsed_args.track: + tracks = cast(list, parsed_args.track) + existing_tracks = { + s.track for s in snap_channel_map.snap.channels if s.track in tracks + } + for track in set(tracks) - existing_tracks: + emit.progress(f"No revisions for track {track!r}") + tracks = list(existing_tracks) + + # If we have no revisions in any of the tracks requested, there's + # nothing to do here. + if not tracks: + return + + emit.message( + get_tabulated_channel_map( + snap_channel_map, + architectures=architectures, + tracks=tracks, + ) + ) + + +class _HINTS: + CLOSED: Final[str] = "-" + FOLLOWING: Final[str] = "↑" + NO_PROGRESS: Final[str] = "-" + PROGRESSING_TO: Final[str] = "→" + UNKNOWN: Final[str] = "?" + + +def _get_channel_order(snap_channels, tracks: Sequence[str]) -> OrderedDict: + channel_order: OrderedDict = OrderedDict() + + if tracks: + snap_channels = [s for s in snap_channels if s.track in tracks] + + for snap_channel in snap_channels: + if snap_channel.track not in channel_order: + channel_order[snap_channel.track] = [] + if snap_channel.fallback is None: + channel_order[snap_channel.track].append(snap_channel.name) + else: + try: + channel_order[snap_channel.track].insert( + channel_order[snap_channel.track].index(snap_channel.fallback) + 1, + snap_channel.name, + ) + except ValueError: + channel_order[snap_channel.track].append(snap_channel.name) + + return channel_order + + +def _get_channel_line( + *, + mapped_channel: Optional[MappedChannel], + revision: Optional[Revision], + channel_info: SnapChannel, + hint: str, + progress_string: str, +) -> List[str]: + version_string = hint + revision_string = hint + expiration_date_string = "" + channel_string = channel_info.risk + + if revision is not None: + version_string = revision.version + revision_string = f"{revision.revision}" + + if mapped_channel is not None: + if channel_info.branch is None and mapped_channel.progressive.percentage: + channel_string = "" + elif channel_info.branch is not None: + channel_string = f"{channel_info.risk}/{channel_info.branch}" + if mapped_channel.expiration_date is not None: + expiration_date_string = mapped_channel.expiration_date + + return [ + channel_string, + version_string, + revision_string, + progress_string, + expiration_date_string, + ] + + +def _get_channel_lines_for_channel( # noqa: C901 # pylint: disable=too-many-locals + snap_channel_map: ChannelMap, + channel_name: str, + architecture: str, + current_tick: str, +) -> Tuple[str, List[List[str]]]: + channel_lines: List[List[str]] = [] + + channel_info = snap_channel_map.get_channel_info(channel_name) + + try: + progressive_mapped_channel: Optional[ + MappedChannel + ] = snap_channel_map.get_mapped_channel( + channel_name=channel_name, architecture=architecture, progressive=True + ) + except ValueError: + progressive_mapped_channel = None + + if progressive_mapped_channel is not None: + progressive_revision = snap_channel_map.get_revision( + progressive_mapped_channel.revision + ) + + if progressive_mapped_channel.progressive.percentage is None: + raise RuntimeError("Unexpected null progressive percentage") + percentage = progressive_mapped_channel.progressive.percentage + + if progressive_mapped_channel.progressive.current_percentage is None: + current_percentage_fmt = _HINTS.UNKNOWN + remaining_percentage_fmt = _HINTS.UNKNOWN + else: + current_percentage = ( + progressive_mapped_channel.progressive.current_percentage + ) + current_percentage_fmt = f"{current_percentage:.0f}" + remaining_percentage_fmt = f"{100 - current_percentage:.0f}" + + progressive_mapped_channel_line = _get_channel_line( + mapped_channel=progressive_mapped_channel, + revision=progressive_revision, + channel_info=channel_info, + hint=current_tick, + progress_string=f"{current_percentage_fmt}{_HINTS.PROGRESSING_TO}{percentage:.0f}%", + ) + # Setup progress for the actually released revision, this needs to be + # calculated. But only show it if the channel is open. + progress_string = ( + f"{remaining_percentage_fmt}{_HINTS.PROGRESSING_TO}{100 - percentage:.0f}%" + ) + else: + progressive_mapped_channel_line = [] + progress_string = _HINTS.NO_PROGRESS + + try: + mapped_channel: Optional[MappedChannel] = snap_channel_map.get_mapped_channel( + channel_name=channel_name, architecture=architecture, progressive=False + ) + except ValueError: + mapped_channel = None + + next_tick = current_tick + if mapped_channel is not None: + revision = snap_channel_map.get_revision(mapped_channel.revision) + channel_lines.append( + _get_channel_line( + mapped_channel=mapped_channel, + revision=revision, + channel_info=channel_info, + hint=current_tick, + progress_string=progress_string, + ) + ) + if channel_info.branch is None: + next_tick = _HINTS.FOLLOWING + # Show an empty entry if there is no specific channel information, but + # only for / (ignoring /). + elif channel_info.branch is None: + channel_lines.append( + _get_channel_line( + mapped_channel=None, + revision=None, + channel_info=channel_info, + hint=current_tick, + progress_string=_HINTS.NO_PROGRESS + if current_tick == _HINTS.CLOSED + else progress_string, + ) + ) + + if progressive_mapped_channel is not None: + channel_lines.append(progressive_mapped_channel_line) + if channel_info.branch is None: + next_tick = _HINTS.FOLLOWING + + return next_tick, channel_lines + + +def _has_channels_for_architecture( + snap_channel_map, architecture: str, channels: List[str] +) -> bool: + progressive = (False, True) + # channel_query = (channel_name, progressive) + for channel_query in itertools.product(channels, progressive): + try: + snap_channel_map.get_mapped_channel( + channel_name=channel_query[0], + architecture=architecture, + progressive=channel_query[1], + ) + found_architecture = True + break + except ValueError: + continue + else: + found_architecture = False + + return found_architecture + + +def get_tabulated_channel_map( # pylint: disable=too-many-branches, too-many-locals # noqa: C901 + snap_channel_map, + *, + architectures: Sequence[str], + tracks: Sequence[str], +): + """Return a tabulated channel map.""" + channel_order = _get_channel_order(snap_channel_map.snap.channels, tracks) + + channel_lines = [] + for track_name in channel_order: + track_mentioned = False + for architecture in sorted(architectures): + if not _has_channels_for_architecture( + snap_channel_map, architecture, channel_order[track_name] + ): + continue + architecture_mentioned = False + next_tick = _HINTS.CLOSED + for channel_name in channel_order[track_name]: + if not track_mentioned: + track_mentioned = True + track_string = track_name + else: + track_string = "" + + if not architecture_mentioned: + architecture_mentioned = True + architecture_string = architecture + else: + architecture_string = "" + + next_tick, parsed_channels = _get_channel_lines_for_channel( + snap_channel_map, channel_name, architecture, next_tick + ) + for channel_line in parsed_channels: + channel_lines.append( + [track_string, architecture_string] + channel_line + ) + track_string = "" + architecture_string = "" + + headers = ["Track", "Arch", "Channel", "Version", "Revision", "Progress"] + expires_column = 6 + + if any(line[expires_column] != "" for line in channel_lines): + headers.append("Expires at") + for index, _ in enumerate(channel_lines): + if not channel_lines[index][expires_column]: + channel_lines[index][expires_column] = "-" + else: + headers.append("") + + return tabulate(channel_lines, numalign="left", headers=headers, tablefmt="plain") + + +class StoreListTracksCommand(BaseCommand): + """Command to list the tracks from a snap on the Snap Store.""" + + name = "list-tracks" + help_msg = "Show the available tracks for a snap on the Snap Store" + overview = textwrap.dedent( + """ + Track status, creation dates and version patterns are returned alongside the + track names in a space formatted table. + + Possible Status values are: + + - active, visible tracks available for installation + - default, the default track to install from when not explicit + - hidden, tracks available for installation but unlisted + - closed, tracks that are no longer available to install from + + A version pattern is a regular expression that restricts a snap revision + from being released to a track if the version string set does not match.""" + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "name", + type=str, + help="The snap name to request the information from on the Snap Store", + ) + + @overrides + def run(self, parsed_args): + snap_channel_map = store.StoreClientCLI().get_channel_map( + snap_name=parsed_args.name + ) + + # Iterate over the entries, replace None with - for consistent presentation + track_table: List[List[str]] = [ + [ + track.name, + track.status, + track.creation_date if track.creation_date else "-", + track.version_pattern if track.version_pattern else "-", + ] + for track in snap_channel_map.snap.tracks + ] + + emit.message( + tabulate( + # Sort by "creation-date". + sorted(track_table, key=operator.itemgetter(2)), + headers=["Name", "Status", "Creation-Date", "Version-Pattern"], + tablefmt="plain", + ) + ) + + +class StoreTracksCommand(StoreListTracksCommand): + """Command alias to list the tracks from a snap on the Snap Store.""" + + name = "tracks" + hidden = True diff --git a/snapcraft/commands/store/__init__.py b/snapcraft/commands/store/__init__.py new file mode 100644 index 0000000000..f5c26953b6 --- /dev/null +++ b/snapcraft/commands/store/__init__.py @@ -0,0 +1,28 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft CLI interface for the Snap Store.""" + + +from . import constants +from .channel_map import ChannelMap +from .client import StoreClientCLI + +__all__ = [ + "ChannelMap", + "StoreClientCLI", + "constants", +] diff --git a/snapcraft/storeapi/v2/channel_map.py b/snapcraft/commands/store/channel_map.py similarity index 55% rename from snapcraft/storeapi/v2/channel_map.py rename to snapcraft/commands/store/channel_map.py index 971b6d8b7b..f4e37fa0f5 100644 --- a/snapcraft/storeapi/v2/channel_map.py +++ b/snapcraft/commands/store/channel_map.py @@ -14,13 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from typing import Any, Dict, List, Optional, Set - -import jsonschema - -from ._api_schema import CHANNEL_MAP_JSONSCHEMA +"""Channel Map API representation. -""" This module holds representations for results for the v2 channel-map API endpoint provided by the Snap Store. @@ -28,12 +23,17 @@ https://dashboard.snapcraft.io/docs/v2/en/snaps.html#snap-channel-map """ +from typing import Any, Dict, List, Optional, Set + +import jsonschema + class Progressive: """Represent Progressive information for a MappedChannel.""" @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "Progressive": + """Unmarshal payload into a Progressive.""" jsonschema.validate( payload, CHANNEL_MAP_JSONSCHEMA["properties"]["channel-map"]["items"]["properties"][ @@ -47,6 +47,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "Progressive": ) def marshal(self) -> Dict[str, Any]: + """Marshal this Progressive into a dict.""" return { "paused": self.paused, "percentage": self.percentage, @@ -54,6 +55,7 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: + """Repr for Progressive.""" return f"<{self.__class__.__name__}: {self.current_percentage!r}=>{self.percentage!r}>" def __init__( @@ -73,6 +75,7 @@ class MappedChannel: @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "MappedChannel": + """Unmarshal payload into a MappedChannel.""" jsonschema.validate( payload, CHANNEL_MAP_JSONSCHEMA["properties"]["channel-map"]["items"] ) @@ -85,6 +88,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "MappedChannel": ) def marshal(self) -> Dict[str, Any]: + """Marshal this MappedChannel into a dict.""" return { "channel": self.channel, "revision": self.revision, @@ -94,7 +98,12 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: - return f"<{self.__class__.__name__}: {self.channel!r} for revision {self.revision!r} and architecture {self.architecture!r}>" + """Repr for MappedChannel.""" + return ( + f"<{self.__class__.__name__}: " + f"{self.channel!r} for revision {self.revision!r} and " + f"architecture {self.architecture!r}>" + ) def __init__( self, @@ -117,6 +126,7 @@ class Revision: @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "Revision": + """Unmarshal payload into a Revision.""" jsonschema.validate( payload, CHANNEL_MAP_JSONSCHEMA["properties"]["revisions"]["items"] ) @@ -127,6 +137,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "Revision": ) def marshal(self) -> Dict[str, Any]: + """Marshal this Revision into a dict.""" return { "revision": self.revision, "version": self.version, @@ -134,7 +145,11 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: - return f"<{self.__class__.__name__}: {self.revision!r} for version {self.version!r} and architectures {self.architectures!r}>" + """Repr for Revision.""" + return ( + f"<{self.__class__.__name__}: {self.revision!r} " + f"for version {self.version!r} and architectures {self.architectures!r}>" + ) def __init__( self, *, revision: int, version: str, architectures: List[str] @@ -149,6 +164,7 @@ class SnapChannel: @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "SnapChannel": + """Unmarshal payload into a SnapChannel.""" jsonschema.validate( payload, CHANNEL_MAP_JSONSCHEMA["properties"]["snap"]["properties"]["channels"][ @@ -164,6 +180,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "SnapChannel": ) def marshal(self) -> Dict[str, Any]: + """Marshal this SnapChannel into a dict.""" return { "name": self.name, "track": self.track, @@ -173,6 +190,7 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: + """Repr for SnapChannel.""" return f"<{self.__class__.__name__}: {self.name!r}>" def __init__( @@ -196,6 +214,7 @@ class SnapTrack: @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "SnapTrack": + """Unmarshal payload into a SnapTrack.""" jsonschema.validate( payload, CHANNEL_MAP_JSONSCHEMA["properties"]["snap"]["properties"]["tracks"][ @@ -210,6 +229,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "SnapTrack": ) def marshal(self) -> Dict[str, Any]: + """Marshal this SnapTrack into a dict.""" return { "name": self.name, "status": self.status, @@ -218,6 +238,7 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: + """Repr for SnapTrack.""" return f"<{self.__class__.__name__}: {self.name!r}>" def __init__( @@ -239,6 +260,7 @@ class Snap: @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "Snap": + """Unmarshal payload into a Snap.""" jsonschema.validate(payload, CHANNEL_MAP_JSONSCHEMA["properties"]["snap"]) return cls( name=payload["name"], @@ -247,6 +269,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "Snap": ) def marshal(self) -> Dict[str, Any]: + """Marshal this Snap into a dict.""" return { "name": self.name, "channels": [sc.marshal() for sc in self.channels], @@ -254,6 +277,7 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: + """Repr for Snap.""" return f"<{self.__class__.__name__}: {self.name!r}>" def __init__( @@ -269,6 +293,7 @@ class ChannelMap: @classmethod def unmarshal(cls, payload: Dict[str, Any]) -> "ChannelMap": + """Unmarshal payload into a ChannelMap.""" jsonschema.validate(payload, CHANNEL_MAP_JSONSCHEMA) return cls( channel_map=[MappedChannel.unmarshal(c) for c in payload["channel-map"]], @@ -277,6 +302,7 @@ def unmarshal(cls, payload: Dict[str, Any]) -> "ChannelMap": ) def marshal(self) -> Dict[str, Any]: + """Marshal this ChannelMap into a dict.""" return { "channel-map": [c.marshal() for c in self.channel_map], "revisions": [r.marshal() for r in self.revisions], @@ -284,6 +310,7 @@ def marshal(self) -> Dict[str, Any]: } def __repr__(self) -> str: + """Repr for ChannelMap.""" return f"<{self.__class__.__name__}: {self.snap.name!r}>" def __init__( @@ -296,6 +323,7 @@ def __init__( def get_mapped_channel( self, *, channel_name: str, architecture: str, progressive: bool ) -> MappedChannel: + """Return the channel for the corresponding attributes.""" channels_with_name = ( cm for cm in self.channel_map if cm.channel == channel_name ) @@ -314,26 +342,208 @@ def get_mapped_channel( try: return channels[0] - except IndexError: + except IndexError as index_error: raise ValueError( - f"No channel mapped to {channel_name!r} for architecture {architecture!r} when progressive is {progressive!r}" - ) + f"No channel mapped to {channel_name!r} for architecture {architecture!r} " + f"when progressive is {progressive!r}" + ) from index_error def get_channel_info(self, channel_name: str) -> SnapChannel: + """Return a SnapChannel for channel_name.""" for snap_channel in self.snap.channels: if snap_channel.name == channel_name: return snap_channel raise ValueError(f"No channel information for {channel_name!r}") def get_revision(self, revision_number: int) -> Revision: + """Return a Revision for revision_number.""" for revision_item in self.revisions: if revision_item.revision == revision_number: return revision_item raise ValueError(f"No revision information for {revision_number!r}") def get_existing_architectures(self) -> Set[str]: - architectures: List[str] = list() + """Return a list of the existing architectures for this map.""" + architectures: List[str] = [] for revision_item in self.revisions: architectures.extend(revision_item.architectures) return set(architectures) + + +CHANNEL_MAP_JSONSCHEMA: Dict[str, Any] = { + "properties": { + "channel-map": { + "items": { + "properties": { + "architecture": {"type": "string"}, + "channel": { + "type": "string", + }, + "expiration-date": { + "format": "date-time", + "type": ["string", "null"], + }, + "progressive": { + "properties": { + "paused": {"type": ["boolean", "null"]}, + "percentage": {"type": ["number", "null"]}, + "current-percentage": {"type": ["number", "null"]}, + }, + "required": ["paused", "percentage", "current-percentage"], + "type": "object", + }, + "revision": {"type": "integer"}, + "when": { + "format": "date-time", + "type": "string", + }, + }, + "required": [ + "architecture", + "channel", + "expiration-date", + "progressive", + "revision", + # "when" + ], + "type": "object", + }, + "minItems": 0, + "type": "array", + }, + "revisions": { + "items": { + "properties": { + "architectures": { + "items": {"type": "string"}, + "minItems": 1, + "type": "array", + }, + "attributes": {"type": "object"}, + "base": {"type": ["string", "null"]}, + "build-url": {"type": ["string", "null"]}, + "confinement": { + "enum": ["strict", "classic", "devmode"], + "type": "string", + }, + "created-at": {"format": "date-time", "type": "string"}, + "epoch": { + "properties": { + "read": { + "items": {"type": "integer"}, + "minItems": 1, + "type": ["array", "null"], + }, + "write": { + "items": {"type": "integer"}, + "minItems": 1, + "type": ["array", "null"], + }, + }, + "required": ["read", "write"], + "type": "object", + }, + "grade": {"enum": ["stable", "devel"], "type": "string"}, + "revision": {"type": "integer"}, + "sha3-384": {"type": "string"}, + "size": {"type": "integer"}, + "version": {"type": "string"}, + }, + "required": [ + "architectures", + # "attributes", + # "base", + # "build-url", + # "confinement", + # "created-at", + # "epoch", + # "grade", + "revision", + # "sha3-384", + # "size", + # "status", + "version", + ], + "type": "object", + }, + "minItems": 0, + "type": "array", + }, + "snap": { + "introduced_at": 6, + "properties": { + "channels": { + "introduced_at": 9, + "items": { + "properties": { + "branch": { + "type": ["string", "null"], + }, + "fallback": { + "type": ["string", "null"], + }, + "name": { + "type": "string", + }, + "risk": { + "type": "string", + }, + "track": { + "type": "string", + }, + }, + "required": ["name", "track", "risk", "branch", "fallback"], + "type": "object", + }, + "minItems": 1, + "type": "array", + }, + "default-track": { + "type": ["string", "null"], + }, + "id": { + "type": "string", + }, + "name": {"type": "string"}, + "private": { + "type": "boolean", + }, + "tracks": { + "introduced_at": 9, + "items": { + "properties": { + "creation-date": { + "format": "date-time", + "type": ["string", "null"], + }, + "name": { + "type": "string", + }, + "version-pattern": { + "type": ["string", "null"], + }, + }, + # pattern is documented as required but is not returned, + # version-pattern is returned instead. + "required": ["name", "creation-date", "version-pattern"], + "type": "object", + }, + "minItems": 1, + "type": "array", + }, + }, + "required": [ + # "id", + "channels", + # "default-track", + "name", + # "private", + # "tracks" + ], + "type": "object", + }, + }, + "required": ["channel-map", "revisions", "snap"], + "type": "object", +} diff --git a/snapcraft/commands/store/client.py b/snapcraft/commands/store/client.py new file mode 100644 index 0000000000..4e2054626c --- /dev/null +++ b/snapcraft/commands/store/client.py @@ -0,0 +1,399 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Store Client with CLI hooks.""" + +import os +import platform +import time +from datetime import timedelta +from typing import Any, Dict, Optional, Sequence, Tuple + +import craft_store +import requests +from craft_cli import emit + +from snapcraft import __version__, errors, utils + +from . import channel_map, constants + +_TESTING_ENV_PREFIXES = ["TRAVIS", "AUTOPKGTEST_TMP"] + +_POLL_DELAY = 1 +_HUMAN_STATUS = { + "being_processed": "processing", + "ready_to_release": "ready to release!", + "need_manual_review": "will need manual review", + "processing_upload_delta_error": "error while processing delta", + "processing_error": "error while processing", +} + + +def build_user_agent( + version=__version__, os_platform: utils.OSPlatform = utils.get_os_platform() +): + """Build Snapcraft's user agent.""" + if any( + key.startswith(prefix) for prefix in _TESTING_ENV_PREFIXES for key in os.environ + ): + testing = " (testing) " + else: + testing = " " + return f"snapcraft/{version}{testing}{os_platform!s}" + + +def use_candid() -> bool: + """Return True if using candid as the auth backend.""" + return os.getenv(constants.ENVIRONMENT_STORE_AUTH) == "candid" + + +def get_store_url() -> str: + """Return the Snap Store url considering the environment.""" + return os.getenv("STORE_DASHBOARD_URL", constants.STORE_URL) + + +def get_store_upload_url() -> str: + """Return the Snap Store Upload url considering the environment.""" + return os.getenv("STORE_UPLOAD_URL", constants.STORE_UPLOAD_URL) + + +def get_store_login_url() -> str: + """Return the Ubuntu Login url considering the environment. + + This is only useful when using Ubuntu One SSO. + """ + return os.getenv("UBUNTU_ONE_SSO_URL", constants.UBUNTU_ONE_SSO_URL) + + +def _prompt_login() -> Tuple[str, str]: + emit.message( + "Enter your Ubuntu One e-mail address and password.", intermediate=True + ) + emit.message( + "If you do not have an Ubuntu One account, you can create one " + "at https://snapcraft.io/account", + intermediate=True, + ) + email = utils.prompt("Email: ") + password = utils.prompt("Password: ", hide=True) + + return (email, password) + + +def _get_hostname(hostname: Optional[str] = platform.node()) -> str: + """Return the computer's network name or UNNKOWN if it cannot be determined.""" + if not hostname: + hostname = "UNKNOWN" + return hostname + + +def get_client(ephemeral: bool) -> craft_store.BaseClient: + """Store Client factory.""" + store_url = get_store_url() + store_upload_url = get_store_upload_url() + user_agent = build_user_agent() + + if use_candid() is True: + client: craft_store.BaseClient = craft_store.StoreClient( + base_url=store_url, + storage_base_url=store_upload_url, + application_name="snapcraft", + user_agent=user_agent, + endpoints=craft_store.endpoints.SNAP_STORE, + environment_auth=constants.ENVIRONMENT_STORE_CREDENTIALS, + ephemeral=ephemeral, + ) + else: + client = craft_store.UbuntuOneStoreClient( + base_url=store_url, + storage_base_url=store_upload_url, + auth_url=get_store_login_url(), + application_name="snapcraft", + user_agent=user_agent, + endpoints=craft_store.endpoints.U1_SNAP_STORE, + environment_auth=constants.ENVIRONMENT_STORE_CREDENTIALS, + ephemeral=ephemeral, + ) + + return client + + +class StoreClientCLI: + """A BaseClient implementation considering command line prompts.""" + + def __init__(self, ephemeral=False): + self.store_client = get_client(ephemeral=ephemeral) + self._base_url = get_store_url() + + def login( + self, + *, + ttl: int = int(timedelta(days=365).total_seconds()), + acls: Optional[Sequence[str]] = None, + packages: Optional[Sequence[str]] = None, + channels: Optional[Sequence[str]] = None, + ) -> str: + """Login to the Snap Store and prompt if required.""" + kwargs: Dict[str, Any] = {} + if use_candid() is False: + kwargs["email"], kwargs["password"] = _prompt_login() + + if packages is None: + packages = [] + _packages = [ + craft_store.endpoints.Package(package_name=p, package_type="snap") + for p in packages + ] + if acls is None: + acls = [ + "package_access", + "package_manage", + "package_metrics", + "package_push", + "package_register", + "package_release", + "package_update", + ] + + description = f"snapcraft@{_get_hostname()}" + + try: + credentials = self.store_client.login( + ttl=ttl, + permissions=acls, + channels=channels, + packages=_packages, + description=description, + **kwargs, + ) + except craft_store.errors.StoreServerError as store_error: + if "twofactor-required" not in store_error.error_list: + raise + kwargs["otp"] = utils.prompt("Second-factor auth: ") + + credentials = self.store_client.login( + ttl=ttl, + permissions=acls, + channels=channels, + packages=_packages, + description=description, + **kwargs, + ) + + return credentials + + def request(self, *args, **kwargs) -> requests.Response: + """Request using the BaseClient and wrap responses that require action. + + Actionable items are those that could prompt a login or registration. + """ + try: + return self.store_client.request(*args, **kwargs) + except craft_store.errors.StoreServerError as store_error: + if ( + store_error.response.status_code + == requests.codes.unauthorized # pylint: disable=no-member + ): + if os.getenv(constants.ENVIRONMENT_STORE_CREDENTIALS): + raise errors.SnapcraftError( + "Provided credentials are no longer valid for the Snap Store. " + "Regenerate them and try again." + ) from store_error + + emit.message( + "You are required to re-login before continuing", + intermediate=True, + ) + self.store_client.logout() + else: + raise + except craft_store.errors.CredentialsUnavailable: + emit.message( + "You are required to login before continuing", intermediate=True + ) + + self.login() + return self.store_client.request(*args, **kwargs) + + def register( + self, + snap_name: str, + *, + is_private: bool = False, + store_id: Optional[str] = None, + ) -> None: + """Register snap_name with the Snap Store. + + :param snap_name: the name of the snap to register with the Snap Store + :param is_private: makes the registered snap a private snap + :param store_id: alternative store to register with + """ + data = dict( + snap_name=snap_name, is_private=is_private, series=constants.DEFAULT_SERIES + ) + if store_id is not None: + data["store"] = store_id + + self.request( + "POST", + self._base_url + "/dev/api/register-name/", + json=data, + ) + + def get_channel_map(self, *, snap_name: str) -> channel_map.ChannelMap: + """Return the channel map for snap_name.""" + response = self.request( + "GET", + self._base_url + f"/api/v2/snaps/{snap_name}/channel-map", + headers={ + "Accept": "application/json", + }, + ) + + return channel_map.ChannelMap.unmarshal(response.json()) + + def get_account_info( + self, + ) -> Dict[str, Any]: + """Return account information.""" + return self.request( + "GET", + self._base_url + "/dev/api/account", + headers={"Accept": "application/json"}, + ).json() + + def release( + self, + snap_name: str, + *, + revision: int, + channels: Sequence[str], + progressive_percentage: Optional[int] = None, + ) -> None: + """Register snap_name with the Snap Store. + + :param snap_name: the name of the snap to register with the Snap Store + :param revision: the revision of the snap to release + :param channels: the channels to release to + :param progressive_percentage: enable progressive releases up to a given percentage + """ + data: Dict[str, Any] = { + "name": snap_name, + "revision": str(revision), + "channels": channels, + } + if progressive_percentage is not None and progressive_percentage != 100: + data["progressive"] = { + "percentage": progressive_percentage, + "paused": False, + } + self.request( + "POST", + self._base_url + "/dev/api/snap-release/", + json=data, + ) + + def close(self, snap_id: str, channel: str) -> None: + """Close channel for snap_id. + + :param snap_id: the id for the snap to close + :param channel: the channel to close + """ + self.request( + "POST", + self._base_url + f"/dev/api/snaps/{snap_id}/close", + json={"channels": [channel]}, + ) + + def verify_upload( + self, + *, + snap_name: str, + ) -> None: + """Verify if this account can perform an upload for this snap_name.""" + data = { + "name": snap_name, + "dry_run": True, + } + self.request( + "POST", + self._base_url + "/dev/api/snap-push/", + json=data, + headers={ + "Accept": "application/json", + }, + ) + + def notify_upload( + self, + *, + snap_name: str, + upload_id: str, + snap_file_size: int, + built_at: Optional[str], + channels: Optional[Sequence[str]], + ) -> int: + """Notify an upload to the Snap Store. + + :param snap_name: name of the snap + :param upload_id: the upload_id to register with the Snap Store + :param snap_file_size: the file size of the uploaded snap + :param built_at: the build timestamp for this build + :param channels: the channels to release to after being accepted into the Snap Store + :returns: the snap's processed revision + """ + data = { + "name": snap_name, + "series": constants.DEFAULT_SERIES, + "updown_id": upload_id, + "binary_filesize": snap_file_size, + "source_uploaded": False, + } + if built_at is not None: + data["built_at"] = built_at + if channels is not None: + data["channels"] = channels + + response = self.request( + "POST", + self._base_url + "/dev/api/snap-push/", + json=data, + headers={ + "Accept": "application/json", + }, + ) + + status_url = response.json()["status_details_url"] + while True: + response = self.request("GET", status_url) + status = response.json() + human_status = _HUMAN_STATUS.get(status["code"], status["code"]) + emit.progress(f"Status: {human_status}") + + if status.get("processed", False): + if status.get("errors"): + error_messages = [ + e["message"] for e in status["errors"] if "message" in e + ] + error_string = "\n".join([f"- {e}" for e in error_messages]) + raise errors.SnapcraftError( + f"Issues while processing snap:\n{error_string}" + ) + break + + time.sleep(_POLL_DELAY) + + return status["revision"] diff --git a/snapcraft/commands/store/constants.py b/snapcraft/commands/store/constants.py new file mode 100644 index 0000000000..ea74dd58a4 --- /dev/null +++ b/snapcraft/commands/store/constants.py @@ -0,0 +1,41 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snap Store constants.""" + +from typing import Final + +ENVIRONMENT_STORE_CREDENTIALS: Final[str] = "SNAPCRAFT_STORE_CREDENTIALS" +"""Environment variable where credentials can be picked up from.""" + +ENVIRONMENT_STORE_AUTH: Final[str] = "SNAPCRAFT_STORE_AUTH" +"""Environment variable used to set an alterntive login method. + +The only setting that changes the behavior is `candid`, every +other value uses Ubuntu SSO. +""" + +STORE_URL: Final[str] = "https://dashboard.snapcraft.io" +"""Default store backend URL.""" + +STORE_UPLOAD_URL: Final[str] = "https://storage.snapcraftcontent.com" +"""Default store upload URL.""" + +UBUNTU_ONE_SSO_URL = "https://login.ubuntu.com" +"""Default Ubuntu One Login URL.""" + +DEFAULT_SERIES = "16" +"""Legacy value for older generation Snap Store APIs.""" diff --git a/snapcraft/commands/upload.py b/snapcraft/commands/upload.py new file mode 100644 index 0000000000..1559e9bf5c --- /dev/null +++ b/snapcraft/commands/upload.py @@ -0,0 +1,116 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft Store uploading related commands.""" + +import pathlib +import textwrap +from typing import TYPE_CHECKING, List, Optional + +from craft_cli import BaseCommand, emit +from craft_cli.errors import ArgumentParsingError +from overrides import overrides +from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor + +from snapcraft import utils +from snapcraft_legacy._store import get_data_from_snap_file + +from . import store + +if TYPE_CHECKING: + import argparse + + +class StoreUploadCommand(BaseCommand): + """Command to upload a snap to the Snap Store.""" + + name = "upload" + help_msg = "Login to the Snap Store" + overview = textwrap.dedent( + """ + By passing --release with a comma separated list of channels the snap would + be released to the selected channels if the store review passes for this + . + + This operation will block until the store finishes processing this . + + If --release is used, the channel map will be displayed after the operation + takes place. + """ + ) + + @overrides + def fill_parser(self, parser: "argparse.ArgumentParser") -> None: + parser.add_argument( + "snap_file", + metavar="", + type=str, + help="Snap to upload", + ) + parser.add_argument( + "--release", + metavar="", + dest="channels", + type=str, + default=None, + help="Optional comma separated list of channels to release to", + ) + + @overrides + def run(self, parsed_args): + snap_file = pathlib.Path(parsed_args.snap_file) + if not snap_file.exists() or not snap_file.is_file(): + raise ArgumentParsingError(f"{str(snap_file)!r} is not a valid file") + + channels: Optional[List[str]] = None + if parsed_args.channels: + channels = parsed_args.channels.split(",") + + client = store.StoreClientCLI() + + snap_yaml = get_data_from_snap_file(snap_file) + snap_name = snap_yaml["name"] + built_at = snap_yaml.get("snapcraft-started-at") + + client.verify_upload(snap_name=snap_name) + + upload_id = client.store_client.upload_file( + filepath=snap_file, monitor_callback=create_callback + ) + + revision = client.notify_upload( + snap_name=snap_name, + upload_id=upload_id, + built_at=built_at, + channels=channels, + snap_file_size=snap_file.stat().st_size, + ) + + message = f"Revision {revision!r} created for {snap_name!r}" + if channels: + message += f" and released to {utils.humanize_list(channels, 'and')}" + emit.message(message) + + +def create_callback(encoder: MultipartEncoder): + """Create a callback suitable for upload_file.""" + with emit.progress_bar("Uploading...", encoder.len, delta=False) as progress: + + def progress_callback(monitor: MultipartEncoderMonitor): + progress.advance(monitor.bytes_read) + + return progress_callback diff --git a/snapcraft/commands/version.py b/snapcraft/commands/version.py new file mode 100644 index 0000000000..74e7ac04f7 --- /dev/null +++ b/snapcraft/commands/version.py @@ -0,0 +1,34 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft version command.""" + +from craft_cli import BaseCommand, emit + +from snapcraft import __version__ + + +class VersionCommand(BaseCommand): + """Show the snapcraft version.""" + + name = "version" + help_msg = "Show the application version and exit" + overview = "Show the application version and exit" + common = True + + def run(self, parsed_args): + """Run the command.""" + emit.message(f"snapcraft {__version__}") diff --git a/snapcraft/errors.py b/snapcraft/errors.py new file mode 100644 index 0000000000..0aff817069 --- /dev/null +++ b/snapcraft/errors.py @@ -0,0 +1,60 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft error definitions.""" + +from craft_cli import CraftError + + +class SnapcraftError(CraftError): + """Failure in a Snapcraft operation.""" + + +class FeatureNotImplemented(SnapcraftError): + """Attempt to use an unimplemented feature.""" + + def __init__(self, msg: str) -> None: + super().__init__(f"Command or feature not implemented: {msg}") + + +class PartsLifecycleError(SnapcraftError): + """Error during parts processing.""" + + +class ProjectValidationError(SnapcraftError): + """Error validatiing snapcraft.yaml.""" + + +class ExtensionError(SnapcraftError): + """Error during parts processing.""" + + +class MetadataExtractionError(SnapcraftError): + """Attempt to extract metadata from file was unsuccessful.""" + + def __init__(self, filename: str, message: str) -> None: + super().__init__(f"Error extracting metadata from {filename!r}: {message}") + + +class DesktopFileError(SnapcraftError): + """Failed to create application desktop file.""" + + def __init__(self, filename: str, message: str) -> None: + super().__init__(f"Failed to generate desktop file {filename!r}: {message}") + + +class LegacyFallback(Exception): + """Fall back to legacy snapcraft implementation.""" diff --git a/snapcraft/extensions/__init__.py b/snapcraft/extensions/__init__.py new file mode 100644 index 0000000000..512ccdcac2 --- /dev/null +++ b/snapcraft/extensions/__init__.py @@ -0,0 +1,30 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Extension processor and related utilities.""" + +from ._extension import Extension +from ._utils import apply_extensions +from .registry import get_extension_class, get_extension_names, register, unregister + +__all__ = [ + "Extension", + "get_extension_class", + "get_extension_names", + "apply_extensions", + "register", + "unregister", +] diff --git a/snapcraft/extensions/_extension.py b/snapcraft/extensions/_extension.py new file mode 100644 index 0000000000..2a34670814 --- /dev/null +++ b/snapcraft/extensions/_extension.py @@ -0,0 +1,124 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2018-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Extension base class definition.""" + +import abc +import os +from typing import Any, Dict, Optional, Tuple, final + +from craft_cli import emit + +from snapcraft import errors + + +class Extension(abc.ABC): + """Extension is the class from which all extensions inherit. + + Extensions have the ability to add snippets to apps, parts, and indeed add new parts + to a given snapcraft.yaml. + + :param yaml_data: Loaded snapcraft.yaml data. + :param arch: the host architecture. + :param target_arch: the target architecture. + """ + + def __init__( + self, *, yaml_data: Dict[str, Any], arch: str, target_arch: str + ) -> None: + """Create a new Extension.""" + self.yaml_data = yaml_data + self.arch = arch + self.target_arch = target_arch + + @staticmethod + @abc.abstractmethod + def get_supported_bases() -> Tuple[str, ...]: + """Return a tuple of supported bases.""" + + @staticmethod + @abc.abstractmethod + def get_supported_confinement() -> Tuple[str, ...]: + """Return a tuple of supported confinement settings.""" + + @staticmethod + @abc.abstractmethod + def is_experimental(base: Optional[str]) -> bool: + """Return whether or not this extension is unstable for given base.""" + + @abc.abstractmethod + def get_root_snippet(self) -> Dict[str, Any]: + """Return the root snippet to apply.""" + + @abc.abstractmethod + def get_app_snippet(self) -> Dict[str, Any]: + """Return the app snippet to apply.""" + + @abc.abstractmethod + def get_part_snippet(self) -> Dict[str, Any]: + """Return the part snippet to apply to existing parts.""" + + @abc.abstractmethod + def get_parts_snippet(self) -> Dict[str, Any]: + """Return the parts to add to parts.""" + + @final + def validate(self, extension_name: str): + """Validate that the extension can be used with the current project. + + :param extension_name: the name of the extension being parsed. + :raises errors.ExtensionError: if the extension is incompatible with the project. + """ + base: str = self.yaml_data["base"] + confinement: Optional[str] = self.yaml_data.get("confinement") + + if self.is_experimental(base) and not os.getenv( + "SNAPCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS" + ): + raise errors.ExtensionError( + f"Extension is experimental: {extension_name!r}", + docs_url="https://snapcraft.io/docs/supported-extensions", + ) + + if self.is_experimental(base): + emit.message( + f"*EXPERIMENTAL* extension {extension_name!r} enabled", + intermediate=True, + ) + + if base not in self.get_supported_bases(): + raise errors.ExtensionError( + f"Extension {extension_name!r} does not support base: {base!r}" + ) + + if ( + confinement is not None + and confinement not in self.get_supported_confinement() + ): + raise errors.ExtensionError( + f"Extension {extension_name!r} does not support confinement {confinement!r}" + ) + + invalid_parts = [ + p + for p in self.get_parts_snippet() + if not p.startswith(f"{extension_name}/") + ] + if invalid_parts: + raise ValueError( + f"Extension has invalid part names: {invalid_parts!r}. " + "Format is /" + ) diff --git a/snapcraft/extensions/_utils.py b/snapcraft/extensions/_utils.py new file mode 100644 index 0000000000..8022963c78 --- /dev/null +++ b/snapcraft/extensions/_utils.py @@ -0,0 +1,136 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2017-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Extension application helpers.""" + +import collections +import contextlib +import copy +from typing import Any, Dict, List, Set + +from ._extension import Extension +from .registry import get_extension_class + + +def apply_extensions( + yaml_data: Dict[str, Any], *, arch: str, target_arch: str +) -> Dict[str, Any]: + """Apply all extensions. + + :param dict yaml_data: Loaded, unprocessed snapcraft.yaml + :param arch: the host architecture. + :param target_arch: the target architecture. + :returns: Modified snapcraft.yaml data with extensions applied + """ + # Don't modify the dict passed in + yaml_data = copy.deepcopy(yaml_data) + + # Mapping of extension names to set of app names to which the extension needs to be + # applied. + declared_extensions: Dict[str, Set[str]] = collections.defaultdict(set) + + for app_name, app_definition in yaml_data.get("apps", {}).items(): + extension_names = app_definition.get("extensions", []) + + for extension_name in extension_names: + declared_extensions[extension_name].add(app_name) + + # Now that we've saved the app -> extension relationship, remove the property + # from this app's declaration in the YAML. + with contextlib.suppress(KeyError): + del yaml_data["apps"][app_name]["extensions"] + + # Process extensions in a consistent order + for extension_name in sorted(declared_extensions.keys()): + extension_class = get_extension_class(extension_name) + extension = extension_class( + yaml_data=copy.deepcopy(yaml_data), arch=arch, target_arch=target_arch + ) + extension.validate(extension_name=extension_name) + _apply_extension(yaml_data, declared_extensions[extension_name], extension) + + return yaml_data + + +def _apply_extension( + yaml_data: Dict[str, Any], + app_names: Set[str], + extension: Extension, +) -> None: + # Apply the root components of the extension (if any) + root_extension = extension.get_root_snippet() + for property_name, property_value in root_extension.items(): + yaml_data[property_name] = _apply_extension_property( + yaml_data.get(property_name), property_value + ) + + # Apply the app-specific components of the extension (if any) + app_extension = extension.get_app_snippet() + for app_name in app_names: + app_definition = yaml_data["apps"][app_name] + for property_name, property_value in app_extension.items(): + app_definition[property_name] = _apply_extension_property( + app_definition.get(property_name), property_value + ) + + # Next, apply the part-specific components + part_extension = extension.get_part_snippet() + parts = yaml_data["parts"] + for part_name, part_definition in parts.items(): + for property_name, property_value in part_extension.items(): + part_definition[property_name] = _apply_extension_property( + part_definition.get(property_name), property_value + ) + + # Finally, add any parts specified in the extension + for part_name, part_definition in extension.get_parts_snippet().items(): + parts[part_name] = part_definition + + +def _apply_extension_property(existing_property: Any, extension_property: Any) -> Any: + if existing_property: + # If the property is not scalar, merge them + if isinstance(existing_property, list) and isinstance(extension_property, list): + merged = extension_property + existing_property + + # If the lists are just strings, remove duplicates. + if all(isinstance(item, str) for item in merged): + return _remove_list_duplicates(merged) + + return merged + + if isinstance(existing_property, dict) and isinstance(extension_property, dict): + for key, value in extension_property.items(): + existing_property[key] = _apply_extension_property( + existing_property.get(key), value + ) + return existing_property + return existing_property + + return extension_property + + +def _remove_list_duplicates(seq: List[str]) -> List[str]: + """De-dupe string list maintaining ordering.""" + seen: Set[str] = set() + deduped: List[str] = [] + + for item in seq: + if item not in seen: + seen.add(item) + deduped.append(item) + + return deduped diff --git a/snapcraft/extensions/registry.py b/snapcraft/extensions/registry.py new file mode 100644 index 0000000000..77864b12a3 --- /dev/null +++ b/snapcraft/extensions/registry.py @@ -0,0 +1,69 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2018-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Extension registry.""" + +from typing import Dict, List, Type + +from snapcraft import errors + +from ._extension import Extension + +ExtensionType = Type[Extension] + +_EXTENSIONS: Dict[str, ExtensionType] = {} + + +def get_extension_names() -> List[str]: + """Obtain a extension class given the name. + + :param name: The extension name. + :return: The list of available extensions. + :raises ExtensionError: If the extension name is invalid. + """ + return list(_EXTENSIONS.keys()) + + +def get_extension_class(extension_name: str) -> ExtensionType: + """Obtain a extension class given the name. + + :param name: The extension name. + :return: The extension class. + :raises ExtensionError: If the extension name is invalid. + """ + try: + return _EXTENSIONS[extension_name] + except KeyError as key_error: + raise errors.ExtensionError( + f"Extension {extension_name!r} does not exist" + ) from key_error + + +def register(extension_name: str, extension_class: ExtensionType) -> None: + """Register extension. + + :param extension_name: the name to register. + :param extension_class: the Extension implementation. + """ + _EXTENSIONS[extension_name] = extension_class + + +def unregister(extension_name: str) -> None: + """Unregister extension_name. + + :raises KeyError: if extension_name is not registered. + """ + del _EXTENSIONS[extension_name] diff --git a/snapcraft/internal/project_loader/grammar/_compound.py b/snapcraft/internal/project_loader/grammar/_compound.py deleted file mode 100644 index aef0fac68a..0000000000 --- a/snapcraft/internal/project_loader/grammar/_compound.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from typing import TYPE_CHECKING, List - -from . import typing -from ._statement import Statement - -# Don't use circular imports unless type checking -if TYPE_CHECKING: - from ._processor import GrammarProcessor # noqa: F401 - - -class CompoundStatement(Statement): - """Multiple statements that need to be treated as a group.""" - - def __init__( - self, - *, - statements: List[Statement], - body: typing.Grammar, - processor: "GrammarProcessor", - call_stack: typing.CallStack = None - ) -> None: - """Create an CompoundStatement instance. - - :param list statements: List of compound statements - :param list body: The body of the clause. - :param GrammarProcessor process: GrammarProcessor to use for processing - this statement. - :param list call_stack: Call stack leading to this statement. - """ - super().__init__(body=body, processor=processor, call_stack=call_stack) - - self.statements = statements - - def _check(self) -> bool: - """Check if each statement checks True, in order - - :return: True if each statement agrees that they should be processed, - False if elses should be processed. - :rtype: bool - """ - for statement in self.statements: - if not statement._check(): - return False - - return True - - def __eq__(self, other) -> bool: - if type(other) is type(self): - return self.statements == other.statements - - return False - - def __str__(self) -> str: - representation = "" - for statement in self.statements: - representation += "{!s} ".format(statement) - - return representation.strip() diff --git a/snapcraft/internal/project_loader/grammar/_on.py b/snapcraft/internal/project_loader/grammar/_on.py deleted file mode 100644 index 7e6269b9c7..0000000000 --- a/snapcraft/internal/project_loader/grammar/_on.py +++ /dev/null @@ -1,130 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import re -from typing import TYPE_CHECKING, Optional, Set - -import snapcraft - -from . import typing -from ._statement import Statement -from .errors import OnStatementSyntaxError - -# Don't use circular imports unless type checking -if TYPE_CHECKING: - from ._processor import GrammarProcessor # noqa: F401 - -_SELECTOR_PATTERN = re.compile(r"\Aon\s+([^,\s](?:,?[^,]+)*)\Z") -_WHITESPACE_PATTERN = re.compile(r"\A.*\s.*\Z") - - -class OnStatement(Statement): - """Process an 'on' statement in the grammar. - - For example: - >>> from snapcraft import ProjectOptions - >>> from snapcraft.internal.project_loader import grammar - >>> from unittest import mock - >>> - >>> def checker(primitive): - ... return True - >>> options = ProjectOptions() - >>> processor = grammar.GrammarProcessor(None, options, checker) - >>> - >>> clause = OnStatement(on='on amd64', body=['foo'], processor=processor) - >>> clause.add_else(['bar']) - >>> with mock.patch('platform.machine') as mock_machine: - ... # Pretend this machine is an i686, not amd64 - ... mock_machine.return_value = 'i686' - ... clause.process() - {'bar'} - """ - - def __init__( - self, - *, - on: str, - body: Optional[typing.Grammar], - processor: "GrammarProcessor", - call_stack: typing.CallStack = None - ) -> None: - """Create an OnStatement instance. - - :param str on: The 'on ' part of the clause. - :param list body: The body of the clause. - :param GrammarProcessor process: GrammarProcessor to use for processing - this statement. - :param list call_stack: Call stack leading to this statement. - """ - super().__init__(body=body, processor=processor, call_stack=call_stack) - - self.selectors = _extract_on_clause_selectors(on) - - def _check(self) -> bool: - """Check if a statement main body should be processed. - - :return: True if main body should be processed, False if elses should - be processed. - :rtype: bool - """ - # A new ProjectOptions instance defaults to the host architecture - # whereas self._project_options would yield the target architecture - host_arch = snapcraft.ProjectOptions().deb_arch - - # The only selector currently supported is the host arch. Since - # selectors are matched with an AND, not OR, there should only be one - # selector. - return (len(self.selectors) == 1) and (host_arch in self.selectors) - - def __eq__(self, other) -> bool: - if type(other) is type(self): - return self.selectors == other.selectors - - return False - - def __str__(self) -> str: - return "on {}".format(",".join(sorted(self.selectors))) - - -def _extract_on_clause_selectors(on: str) -> Set[str]: - """Extract the list of selectors within an on clause. - - :param str on: The 'on ' part of the 'on' clause. - - :return: Selectors found within the 'on' clause. - - For example: - >>> _extract_on_clause_selectors('on amd64,i386') == {'amd64', 'i386'} - True - """ - - match = _SELECTOR_PATTERN.match(on) - if match is None: - raise OnStatementSyntaxError(on, message="selectors are missing") - - try: - selector_group = match.group(1) - except IndexError: - raise OnStatementSyntaxError(on) - - # This could be part of the _SELECTOR_PATTERN, but that would require us - # to provide a very generic error when we can try to be more helpful. - if _WHITESPACE_PATTERN.match(selector_group): - raise OnStatementSyntaxError( - on, message="spaces are not allowed in the selectors" - ) - - return {selector.strip() for selector in selector_group.split(",")} diff --git a/snapcraft/internal/project_loader/grammar/_processor.py b/snapcraft/internal/project_loader/grammar/_processor.py deleted file mode 100644 index b22dd8b65e..0000000000 --- a/snapcraft/internal/project_loader/grammar/_processor.py +++ /dev/null @@ -1,287 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import re -from typing import Any, Callable, Dict, List, Optional, Tuple - -from snapcraft import project - -from . import typing -from ._compound import CompoundStatement -from ._on import OnStatement -from ._statement import Statement -from ._to import ToStatement -from ._try import TryStatement -from .errors import GrammarSyntaxError - -_ON_TO_CLAUSE_PATTERN = re.compile(r"(\Aon\s+\S+)\s+(to\s+\S+\Z)") -_ON_CLAUSE_PATTERN = re.compile(r"\Aon\s+") -_TO_CLAUSE_PATTERN = re.compile(r"\Ato\s+") -_TRY_CLAUSE_PATTERN = re.compile(r"\Atry\Z") -_ELSE_CLAUSE_PATTERN = re.compile(r"\Aelse\Z") -_ELSE_FAIL_PATTERN = re.compile(r"\Aelse\s+fail\Z") - - -class GrammarProcessor: - """The GrammarProcessor extracts desired primitives from grammar.""" - - def __init__( - self, - grammar: typing.Grammar, - project: project.Project, - checker: Callable[[Any], bool], - *, - transformer: Callable[[List[Statement], str, project.Project], str] = None, - ) -> None: - """Create a new GrammarProcessor. - - :param list grammar: Unprocessed grammar. - :param project: Instance of Project to use to determine appropriate - primitives. - :type project: snapcraft.project.Project - :param callable checker: callable accepting a single primitive, - returning true if it is valid. - :param callable transformer: callable accepting a call stack, single - primitive, and project, and returning a - transformed primitive. - """ - self._grammar = grammar - self.project = project - self.checker = checker - - if transformer: - self._transformer = transformer - else: - # By default, no transformation - self._transformer = lambda s, p, o: p - - def process( - self, *, grammar: typing.Grammar = None, call_stack: typing.CallStack = None - ) -> List[Any]: - """Process grammar and extract desired primitives. - - :param list grammar: Unprocessed grammar (defaults to that set in - init). - :param list call_stack: Call stack of statements leading to now. - - :return: Primitives selected - """ - - if grammar is None: - grammar = self._grammar - - if call_stack is None: - call_stack = [] - - primitives: List[Any] = list() - statements = _StatementCollection() - statement: Optional[Statement] = None - - for section in grammar: - if isinstance(section, str): - # If the section is just a string, it's either "else fail" or a - # primitive name. - if _ELSE_FAIL_PATTERN.match(section): - _handle_else(statement, None) - else: - # Processing a string primitive indicates the previous section - # is finalized (if any), process it first before this primitive. - self._process_statement( - statement=statement, - statements=statements, - primitives=primitives, - ) - statement = None - - primitive = self._transformer(call_stack, section, self.project) - primitives.append(primitive) - elif isinstance(section, dict): - statement, finalized_statement = self._parse_section_dictionary( - call_stack=call_stack, - section=section, - statement=statement, - ) - - # Process any finalized statement (if any). - if finalized_statement is not None: - self._process_statement( - statement=finalized_statement, - statements=statements, - primitives=primitives, - ) - - # If this section does not belong to a statement, it is - # a primitive to be recorded. - if statement is None: - primitives.append(section) - - else: - # jsonschema should never let us get here. - raise GrammarSyntaxError( - "expected grammar section to be either of type 'str' or " - "type 'dict', but got {!r}".format(type(section)) - ) - - # Process the final statement (if any). - self._process_statement( - statement=statement, - statements=statements, - primitives=primitives, - ) - - return primitives - - def _process_statement( - self, - *, - statement: Optional[Statement], - statements: "_StatementCollection", - primitives: List[Any], - ): - if statement is None: - return - - statements.add(statement) - processed_primitives = statement.process() - primitives.extend(processed_primitives) - - def _parse_section_dictionary( - self, - *, - section: Dict[str, Any], - statement: Optional[Statement], - call_stack: typing.CallStack, - ) -> Tuple[Optional[Statement], Optional[Statement]]: - finalized_statement: Optional[Statement] = None - for key, value in section.items(): - # Grammar is always written as a list of selectors but the value - # can be a list or a string. In the latter case we wrap it so no - # special care needs to be taken when fetching the result from the - # primitive. - if not isinstance(value, list): - value = [value] - - on_to_clause_match = _ON_TO_CLAUSE_PATTERN.match(key) - on_clause_match = _ON_CLAUSE_PATTERN.match(key) - if on_to_clause_match: - # We've come across the beginning of a compound statement - # with both 'on' and 'to'. - finalized_statement = statement - - # First, extract each statement's part of the string - on, to = on_to_clause_match.groups() - - # Now create a list of statements, in order - compound_statements = [ - OnStatement( - on=on, body=None, processor=self, call_stack=call_stack - ), - ToStatement( - to=to, body=None, processor=self, call_stack=call_stack - ), - ] - - # Now our statement is a compound statement - statement = CompoundStatement( - statements=compound_statements, - body=value, - processor=self, - call_stack=call_stack, - ) - - elif on_clause_match: - # We've come across the beginning of an 'on' statement. - # That means any previous statement we found is complete. - finalized_statement = statement - - statement = OnStatement( - on=key, body=value, processor=self, call_stack=call_stack - ) - - elif _TO_CLAUSE_PATTERN.match(key): - # We've come across the beginning of a 'to' statement. - # That means any previous statement we found is complete. - finalized_statement = statement - - statement = ToStatement( - to=key, body=value, processor=self, call_stack=call_stack - ) - - elif _TRY_CLAUSE_PATTERN.match(key): - # We've come across the beginning of a 'try' statement. - # That means any previous statement we found is complete. - finalized_statement = statement - - statement = TryStatement( - body=value, processor=self, call_stack=call_stack - ) - - elif _ELSE_CLAUSE_PATTERN.match(key): - _handle_else(statement, value) - else: - # Since this section is a dictionary, if there are no - # markers to indicate the start or change of statement, - # the current statement is complete and this section - # is a primitive to be collected. - finalized_statement = statement - statement = None - - return statement, finalized_statement - - -def _handle_else(statement: Optional[Statement], else_body: Optional[typing.Grammar]): - """Add else body to current statement. - - :param statement: The currently-active statement. If None it will be - ignored. - :param else_body: The body of the else clause to add. - - :raises GrammarSyntaxError: If there isn't a currently-active - statement. - """ - - if statement is None: - raise GrammarSyntaxError( - "'else' doesn't seem to correspond to an 'on' or 'try'" - ) - - statement.add_else(else_body) - - -class _StatementCollection: - """Unique collection of statements to run at a later time.""" - - def __init__(self) -> None: - self._statements = [] # type: List[Statement] - - def add(self, statement: Optional[Statement]) -> None: - """Add new statement to collection. - - :param statement: New statement. - - :raises GrammarSyntaxError: If statement is already in collection. - """ - - if not statement: - return - - if statement in self._statements: - raise GrammarSyntaxError( - "found duplicate {!r} statements. These should be " - "merged.".format(statement) - ) - - self._statements.append(statement) diff --git a/snapcraft/internal/project_loader/grammar/_statement.py b/snapcraft/internal/project_loader/grammar/_statement.py deleted file mode 100644 index 783c28efec..0000000000 --- a/snapcraft/internal/project_loader/grammar/_statement.py +++ /dev/null @@ -1,166 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import re -from typing import TYPE_CHECKING, Iterable, List, Optional - -from . import typing -from .errors import UnsatisfiedStatementError - -# Don't use circular imports unless type checking -if TYPE_CHECKING: - from ._processor import GrammarProcessor # noqa: F401 - -_SELECTOR_PATTERN = re.compile(r"\Aon\s+([^,\s](?:,?[^,]+)*)\Z") -_WHITESPACE_PATTERN = re.compile(r"\A.*\s.*\Z") - - -class Statement: - """Base class for all grammar statements""" - - def __init__( - self, - *, - body: Optional[typing.Grammar], - processor: "GrammarProcessor", - call_stack: Optional[typing.CallStack], - check_primitives: bool = False - ) -> None: - """Create an Statement instance. - - :param list body: The body of the clause. - :param GrammarProcessor process: GrammarProcessor to use for processing - this statement. - :param list call_stack: Call stack leading to this statement. - :param bool check_primitives: Whether or not the primitives should be - checked for validity as part of - evaluating the elses. - """ - if call_stack: - self.__call_stack = call_stack - else: - self.__call_stack = [] - - self._body = body - self._processor = processor - self._check_primitives = check_primitives - self._else_bodies: List[Optional[typing.Grammar]] = [] - - self.__processed_body: Optional[List[str]] = None - self.__processed_else: Optional[List[str]] = None - - def add_else(self, else_body: Optional[typing.Grammar]) -> None: - """Add an 'else' clause to the statement. - - :param list else_body: The body of an 'else' clause. - - The 'else' clauses will be processed in the order they are added. - """ - self._else_bodies.append(else_body) - - def process(self) -> List[str]: - """Process this statement. - - :return: Primitives as determined by evaluating the statement or its - else clauses. - """ - if self._check(): - return self._process_body() - else: - return self._process_else() - - def _process_body(self) -> List[str]: - """Process the main body of this statement. - - :return: Primitives as determined by processing the main body. - """ - if self.__processed_body is None: - self.__processed_body = self._processor.process( - grammar=self._body, call_stack=self._call_stack(include_self=True) - ) - - return self.__processed_body - - def _process_else(self) -> List[str]: - """Process the else clauses of this statement in order. - - :return: Primitives as determined by processing the else clauses. - """ - if self.__processed_else is not None: - return self.__processed_else - - self.__processed_else = list() - for else_body in self._else_bodies: - if not else_body: - # Handle the 'else fail' case. - raise UnsatisfiedStatementError(self) - - processed_else = self._processor.process( - grammar=else_body, call_stack=self._call_stack() - ) - if processed_else: - self.__processed_else = processed_else - if not self._check_primitives or self._validate_primitives( - processed_else - ): - break - - return self.__processed_else - - def _validate_primitives(self, primitives: Iterable[str]) -> bool: - """Ensure that all primitives are valid. - - :param primitives: Iterable container of primitives. - - :return: Whether or not all primitives are valid. - :rtype: bool - """ - for primitive in primitives: - if not self._processor.checker(primitive): - return False - return True - - def _call_stack(self, *, include_self=False) -> List["Statement"]: - """The call stack when processing this statement. - - :param bool include_self: Whether or not this statement should be - included in the stack. - - :return: The call stack - :rtype: list - """ - if include_self: - return self.__call_stack + [self] - else: - return self.__call_stack - - def __repr__(self): - return "{!r}".format(self.__str__()) - - def _check(self) -> bool: - """Check if a statement main body should be processed. - - :return: True if main body should be processed, False if elses should - be processed. - :rtype: bool - """ - raise NotImplementedError("this must be implemented by child classes") - - def __eq__(self, other) -> bool: - raise NotImplementedError("this must be implemented by child classes") - - def __str__(self) -> str: - raise NotImplementedError("this must be implemented by child classes") diff --git a/snapcraft/internal/project_loader/grammar/_to.py b/snapcraft/internal/project_loader/grammar/_to.py deleted file mode 100644 index 8166e11fbe..0000000000 --- a/snapcraft/internal/project_loader/grammar/_to.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import re -from typing import TYPE_CHECKING, Optional, Set - -from . import typing -from ._statement import Statement -from .errors import ToStatementSyntaxError - -# Don't use circular imports unless type checking -if TYPE_CHECKING: - from ._processor import GrammarProcessor # noqa: F401 - -_SELECTOR_PATTERN = re.compile(r"\Ato\s+([^,\s](?:,?[^,]+)*)\Z") -_WHITESPACE_PATTERN = re.compile(r"\A.*\s.*\Z") - - -class ToStatement(Statement): - """Process a 'to' statement in the grammar. - - For example: - >>> import tempfile - >>> from snapcraft import ProjectOptions - >>> from snapcraft.internal.project_loader import grammar - >>> def checker(primitive): - ... return True - >>> options = ProjectOptions(target_deb_arch='i386') - >>> processor = grammar.GrammarProcessor(None, options, checker) - >>> clause = ToStatement(to='to armhf', body=['foo'], processor=processor) - >>> clause.add_else(['bar']) - >>> clause.process() - {'bar'} - """ - - def __init__( - self, - *, - to: str, - body: Optional[typing.Grammar], - processor: "GrammarProcessor", - call_stack: typing.CallStack = None - ) -> None: - """Create a ToStatement instance. - - :param str to: The 'to ' part of the clause. - :param list body: The body of the clause. - :param GrammarProcessor process: GrammarProcessor to use for processing - this statement. - :param list call_stack: Call stack leading to this statement. - """ - super().__init__(body=body, processor=processor, call_stack=call_stack) - - self.selectors = _extract_to_clause_selectors(to) - - def _check(self) -> bool: - """Check if a statement main body should be processed. - - :return: True if main body should be processed, False if elses should - be processed. - :rtype: bool - """ - target_arch = self._processor.project.deb_arch - - # The only selector currently supported is the target arch. Since - # selectors are matched with an AND, not OR, there should only be one - # selector. - return (len(self.selectors) == 1) and (target_arch in self.selectors) - - def __eq__(self, other) -> bool: - if type(other) is type(self): - return self.selectors == other.selectors - - return False - - def __str__(self) -> str: - return "to {}".format(",".join(sorted(self.selectors))) - - -def _extract_to_clause_selectors(to: str) -> Set[str]: - """Extract the list of selectors within a to clause. - - :param str to: The 'to ' part of the 'to' clause. - - :return: Selectors found within the 'to' clause. - - For example: - >>> _extract_to_clause_selectors('to amd64,i386') == {'amd64', 'i386'} - True - """ - - match = _SELECTOR_PATTERN.match(to) - if match is None: - raise ToStatementSyntaxError(to, message="selectors are missing") - - try: - selector_group = match.group(1) - except IndexError: - raise ToStatementSyntaxError(to) - - # This could be part of the _SELECTOR_PATTERN, but that would require us - # to provide a very generic error when we can try to be more helpful. - if _WHITESPACE_PATTERN.match(selector_group): - raise ToStatementSyntaxError( - to, message="spaces are not allowed in the selectors" - ) - - return {selector.strip() for selector in selector_group.split(",")} diff --git a/snapcraft/internal/project_loader/grammar/_try.py b/snapcraft/internal/project_loader/grammar/_try.py deleted file mode 100644 index 9521e599a3..0000000000 --- a/snapcraft/internal/project_loader/grammar/_try.py +++ /dev/null @@ -1,74 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from typing import TYPE_CHECKING - -from . import typing -from ._statement import Statement - -# Don't use circular imports unless type checking -if TYPE_CHECKING: - from ._processor import GrammarProcessor # noqa: F401 - - -class TryStatement(Statement): - """Process a 'try' statement in the grammar. - - For example: - >>> from snapcraft import ProjectOptions - >>> from ._processor import GrammarProcessor - >>> def checker(primitive): - ... return 'invalid' not in primitive - >>> options = ProjectOptions() - >>> processor = GrammarProcessor(None, options, checker) - >>> clause = TryStatement(body=['invalid'], processor=processor) - >>> clause.add_else(['valid']) - >>> clause.process() - {'valid'} - """ - - def __init__( - self, - *, - body: typing.Grammar, - processor: "GrammarProcessor", - call_stack: typing.CallStack = None - ) -> None: - """Create a TryStatement instance. - - :param list body: The body of the clause. - :param GrammarProcessor process: GrammarProcessor to use for processing - this statement. - :param list call_stack: Call stack leading to this statement. - """ - super().__init__( - body=body, processor=processor, call_stack=call_stack, check_primitives=True - ) - - def _check(self) -> bool: - """Check if a statement main body should be processed. - - :return: True if main body should be processed, False if elses should - be processed. - :rtype: bool - """ - return self._validate_primitives(self._process_body()) - - def __eq__(self, other) -> bool: - return False - - def __str__(self) -> str: - return "try" diff --git a/snapcraft/internal/project_loader/grammar/errors.py b/snapcraft/internal/project_loader/grammar/errors.py deleted file mode 100644 index 50768e9f76..0000000000 --- a/snapcraft/internal/project_loader/grammar/errors.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from snapcraft.internal import errors - - -class GrammarError(errors.SnapcraftError): - """Base class for grammar-related errors.""" - - pass - - -class GrammarSyntaxError(GrammarError): - - fmt = "Invalid grammar syntax: {message}" - - def __init__(self, message): - super().__init__(message=message) - - -class OnStatementSyntaxError(GrammarSyntaxError): - def __init__(self, on_statement, *, message=None): - components = ["{!r} is not a valid 'on' clause".format(on_statement)] - if message: - components.append(message) - super().__init__(message=": ".join(components)) - - -class ToStatementSyntaxError(GrammarSyntaxError): - def __init__(self, to_statement, *, message=None): - components = ["{!r} is not a valid 'to' clause".format(to_statement)] - if message: - components.append(message) - super().__init__(message=": ".join(components)) - - -class UnsatisfiedStatementError(GrammarError): - - fmt = "Unable to satisfy {statement!r}, failure forced" - - def __init__(self, statement): - super().__init__(statement=statement) diff --git a/snapcraft/internal/project_loader/grammar/typing.py b/snapcraft/internal/project_loader/grammar/typing.py deleted file mode 100644 index 95b98c537a..0000000000 --- a/snapcraft/internal/project_loader/grammar/typing.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import Any, Dict, List, Sequence, Union - -Grammar = Sequence[Union[str, Dict[str, Any]]] -CallStack = List["Statement"] - -from ._statement import Statement # noqa: F401 diff --git a/snapcraft/internal/project_loader/grammar/__init__.py b/snapcraft/meta/__init__.py similarity index 74% rename from snapcraft/internal/project_loader/grammar/__init__.py rename to snapcraft/meta/__init__.py index c1b7095226..70c7932f24 100644 --- a/snapcraft/internal/project_loader/grammar/__init__.py +++ b/snapcraft/meta/__init__.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2017, 2018 Canonical Ltd +# Copyright 2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from ._compound import CompoundStatement # noqa -from ._processor import GrammarProcessor # noqa -from ._statement import Statement # noqa -from ._to import ToStatement # noqa +"""Snap metadata definitions and helpers.""" + +from .extracted_metadata import ExtractedMetadata # noqa: F401 +from .metadata import extract_metadata # noqa: F401 diff --git a/snapcraft/meta/appstream.py b/snapcraft/meta/appstream.py new file mode 100644 index 0000000000..40862d9ebe --- /dev/null +++ b/snapcraft/meta/appstream.py @@ -0,0 +1,278 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2017-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Appstream metadata extractor.""" + +import contextlib +import operator +import os +from io import StringIO +from typing import List, Optional + +import lxml.etree +from xdg.DesktopEntry import DesktopEntry + +from snapcraft import errors + +from .extracted_metadata import ExtractedMetadata + +_XSLT = """\ + + + + + + + + + + + + + + + + + + + + + + +- + + + + + + + + + + + + + + + +_ + +_ + + + + + + + +""" + + +def extract(relpath: str, *, workdir: str) -> Optional[ExtractedMetadata]: + """Extract appstream metadata. + + :param file_relpath: Relative path to the file containing metadata. + :param workdir: The part working directory where the metadata file is located. + + :return: The extracted metadata, if any. + """ + if not relpath.endswith(".metainfo.xml") and not relpath.endswith(".appdata.xml"): + return None + + dom = _get_transformed_dom(os.path.join(workdir, relpath)) + + common_id = _get_value_from_xml_element(dom, "id") + summary = _get_value_from_xml_element(dom, "summary") + description = _get_value_from_xml_element(dom, "description") + title = _get_value_from_xml_element(dom, "name") + version = _get_latest_release_from_nodes(dom.findall("releases/release")) + + desktop_file_paths = [] + desktop_file_ids = _get_desktop_file_ids_from_nodes(dom.findall("launchable")) + # if there are no launchables, use the appstream id to take into + # account the legacy appstream definitions + if common_id and not desktop_file_ids: + if common_id.endswith(".desktop"): + desktop_file_ids.append(common_id) + else: + desktop_file_ids.append(common_id + ".desktop") + + for desktop_file_id in desktop_file_ids: + desktop_file_path = _desktop_file_id_to_path(desktop_file_id, workdir=workdir) + if desktop_file_path: + desktop_file_paths.append(desktop_file_path) + + icon = _extract_icon(dom, workdir, desktop_file_paths) + + return ExtractedMetadata( + common_id=common_id, + title=title, + summary=summary, + description=description, + version=version, + icon=icon, + desktop_file_paths=desktop_file_paths, + ) + + +def _get_transformed_dom(path: str): + dom = _get_dom(path) + transform = _get_xslt() + return transform(dom) + + +def _get_dom(path: str) -> lxml.etree.ElementTree: + try: + return lxml.etree.parse(path) + except OSError as err: + raise errors.SnapcraftError(str(err)) from err + except lxml.etree.ParseError as err: + raise errors.MetadataExtractionError(path, str(err)) from err + + +def _get_xslt(): + xslt = lxml.etree.parse(StringIO(_XSLT)) + return lxml.etree.XSLT(xslt) + + +def _get_value_from_xml_element(tree, key) -> Optional[str]: + node = tree.find(key) + if node is not None and node.text: + # Lines that should be empty end up with empty space after the + # transformation. One example of this is seen for paragraphs (i.e.;

) + # than hold list in then (i.e.;

    or
      ) so we split all lines + # here and strip any unwanted space. + # TODO: Improve the XSLT to remove the need for this. + return "\n".join([n.strip() for n in node.text.splitlines()]).strip() + return None + + +def _get_latest_release_from_nodes(nodes) -> Optional[str]: + for node in nodes: + if "version" in node.attrib: + return node.attrib["version"] + return None + + +def _get_desktop_file_ids_from_nodes(nodes) -> List[str]: + desktop_file_ids = [] # type: List[str] + for node in nodes: + if "type" in node.attrib and node.attrib["type"] == "desktop-id": + desktop_file_ids.append(node.text.strip()) + return desktop_file_ids + + +def _desktop_file_id_to_path(desktop_file_id: str, *, workdir: str) -> Optional[str]: + # For details about desktop file ids and their corresponding paths, see + # https://standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html#desktop-file-id + for xdg_data_dir in ("usr/local/share", "usr/share"): + desktop_file_path = os.path.join( + xdg_data_dir, "applications", desktop_file_id.replace("-", "/") + ) + # Check if it exists in workdir, but do not add it to the resulting path + # as it later needs to exist in the prime directory to effectively be + # used. + if os.path.exists(os.path.join(workdir, desktop_file_path)): + return desktop_file_path + return None + + +def _extract_icon(dom, workdir: str, desktop_file_paths: List[str]) -> Optional[str]: + icon_node = dom.find("icon") + if icon_node is not None and "type" in icon_node.attrib: + icon_node_type = icon_node.attrib["type"] + else: + icon_node_type = None + + icon = icon_node.text.strip() if icon_node is not None else None + + if icon_node_type == "remote": + return icon + + if icon_node_type == "stock": + return _get_icon_from_theme(workdir, "hicolor", icon) + + # If an icon path is specified and the icon file exists, we'll use that, otherwise + # we'll fall back to what's listed in the desktop file. + if icon is None: + return _get_icon_from_desktop_file(workdir, desktop_file_paths) + + if os.path.exists(os.path.join(workdir, icon.lstrip("/"))): + return icon + + return _get_icon_from_desktop_file(workdir, desktop_file_paths) + + +def _get_icon_from_desktop_file( + workdir: str, desktop_file_paths: List[str] +) -> Optional[str]: + # Icons in the desktop file can be either a full path to the icon file, or a name + # to be searched in the standard locations. If the path is specified, use that, + # otherwise look for the icon in the hicolor theme (also covers icon type="stock"). + # See https://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html + # for further information. + for path in desktop_file_paths: + entry = DesktopEntry() + entry.parse(os.path.join(workdir, path)) + icon = entry.getIcon() + icon_path = ( + icon + if os.path.isabs(icon) + else _get_icon_from_theme(workdir, "hicolor", icon) + ) + return icon_path + + return None + + +def _get_icon_from_theme(workdir: str, theme: str, icon: str) -> Optional[str]: + # Icon themes can carry icons in different pre-rendered sizes or scalable. Scalable + # implementation is optional, so we'll try the largest pixmap and then scalable if + # no other sizes are available. + theme_dir = os.path.join("usr", "share", "icons", theme) + if not os.path.exists(os.path.join(workdir, theme_dir)): + return None + + # TODO: use index.theme + entries = os.listdir(os.path.join(workdir, theme_dir)) + # size is NxN + x_entries = (e.split("x") for e in entries if "x" in e) + sized_entries = (e[0] for e in x_entries if e[0] == e[1]) + sizes = {} + for icon_size_entry in sized_entries: + with contextlib.suppress(ValueError): + isize = int(icon_size_entry) + sizes[isize] = f"{isize}x{isize}" + + icon_size = None + suffixes = [] + if sizes: + size = max(sizes.items(), key=operator.itemgetter(1))[0] + icon_size = sizes[size] + suffixes = [".png", ".xpm"] + elif "scalable" in entries: + icon_size = "scalable" + suffixes = [".svg", ".svgz"] + + icon_path = None + if icon_size: + for suffix in suffixes: + icon_path = os.path.join(theme_dir, icon_size, "apps", icon + suffix) + if os.path.exists(os.path.join(workdir, icon_path)): + break + + return icon_path diff --git a/snapcraft/meta/extracted_metadata.py b/snapcraft/meta/extracted_metadata.py new file mode 100644 index 0000000000..18823cfd45 --- /dev/null +++ b/snapcraft/meta/extracted_metadata.py @@ -0,0 +1,49 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2017-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""External metadata definition.""" + +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class ExtractedMetadata: + """Collection of metadata extracted from a part.""" + + common_id: Optional[str] = None + """The common identifier across multiple packaging formats.""" + + title: Optional[str] = None + """The extracted package title.""" + + summary: Optional[str] = None + """The extracted package summary.""" + + description: Optional[str] = None + """The extracted package description.""" + + version: Optional[str] = None + """The extracted package version.""" + + grade: Optional[str] = None + """The extracted package version.""" + + icon: Optional[str] = None + """The extracted application icon.""" + + desktop_file_paths: List[str] = field(default_factory=list) + """The extracted application desktop file paths.""" diff --git a/snapcraft/meta/metadata.py b/snapcraft/meta/metadata.py new file mode 100644 index 0000000000..edd775088a --- /dev/null +++ b/snapcraft/meta/metadata.py @@ -0,0 +1,33 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""External metadata helpers.""" + +from typing import Optional + +from . import appstream +from .extracted_metadata import ExtractedMetadata + + +def extract_metadata(file_relpath: str, *, workdir: str) -> Optional[ExtractedMetadata]: + """Retrieve external metadata from part files. + + :param file_relpath: Relative path to the file containing metadata. + :param workdir: The part working directory where the metadata file is located. + + :return: The extracted metadata, if any. + """ + return appstream.extract(file_relpath, workdir=workdir) diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py new file mode 100644 index 0000000000..417a15d9d8 --- /dev/null +++ b/snapcraft/meta/snap_yaml.py @@ -0,0 +1,201 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Create snap.yaml metadata file.""" + +from pathlib import Path +from typing import Any, Dict, List, Optional, Union, cast + +import yaml +from pydantic_yaml import YamlModel + +from snapcraft.projects import Project + + +class Socket(YamlModel): + """snap.yaml app socket entry.""" + + listen_stream: Union[int, str] + socket_mode: Optional[int] + + class Config: # pylint: disable=too-few-public-methods + """Pydantic model configuration.""" + + allow_population_by_field_name = True + alias_generator = lambda s: s.replace("_", "-") # noqa: E731 + + +class SnapApp(YamlModel): + """Snap.yaml app entry. + + This is currently a partial implementation, see + https://snapcraft.io/docs/snap-format for details. + + TODO: implement desktop (CRAFT-804) + TODO: implement extensions (CRAFT-805) + TODO: implement passthrough (CRAFT-854) + TODO: implement slots (CRAFT-816) + """ + + command: str + autostart: Optional[str] + common_id: Optional[str] + bus_name: Optional[str] + completer: Optional[str] + stop_command: Optional[str] + post_stop_command: Optional[str] + start_timeout: Optional[str] + stop_timeout: Optional[str] + watchdog_timeout: Optional[str] + reload_command: Optional[str] + restart_delay: Optional[str] + timer: Optional[str] + daemon: Optional[str] + after: Optional[List[str]] + before: Optional[List[str]] + refresh_mode: Optional[str] + stop_mode: Optional[str] + restart_condition: Optional[str] + install_mode: Optional[str] + plugs: Optional[List[str]] + aliases: Optional[List[str]] + environment: Optional[Dict[str, Any]] + adapter: Optional[str] + command_chain: List[str] + sockets: Optional[Dict[str, Socket]] + + class Config: # pylint: disable=too-few-public-methods + """Pydantic model configuration.""" + + allow_population_by_field_name = True + alias_generator = lambda s: s.replace("_", "-") # noqa: E731 + + +class SnapMetadata(YamlModel): + """The snap.yaml model. + + This is currently a partial implementation, see + https://snapcraft.io/docs/snap-format for details. + + TODO: implement adopt-info (CRAFT-803) + """ + + name: str + title: Optional[str] + version: str + summary: str + description: str + license: Optional[str] + type: Optional[str] + architectures: List[str] + base: Optional[str] + build_base: Optional[str] + assumes: Optional[List[str]] + epoch: Optional[str] + apps: Optional[Dict[str, SnapApp]] + confinement: str + grade: str + environment: Optional[Dict[str, Any]] + plugs: Optional[Dict[str, Any]] + hooks: Optional[Dict[str, Any]] + layout: Optional[Dict[str, Dict[str, str]]] + + +def write(project: Project, prime_dir: Path, *, arch: str): + """Create a snap.yaml file.""" + meta_dir = prime_dir / "meta" + meta_dir.mkdir(parents=True, exist_ok=True) + + snap_apps: Dict[str, SnapApp] = {} + if project.apps: + for name, app in project.apps.items(): + + app_sockets: Dict[str, Socket] = {} + if app.sockets: + for socket_name, socket in app.sockets.items(): + app_sockets[socket_name] = Socket( + listen_stream=socket.listen_stream, + socket_mode=socket.socket_mode, + ) + + snap_apps[name] = SnapApp( + command=app.command, + autostart=app.autostart, + common_id=app.common_id, + bus_name=app.bus_name, + completer=app.completer, + stop_command=app.stop_command, + post_stop_command=app.post_stop_command, + start_timeout=app.start_timeout, + stop_timeout=app.stop_timeout, + watchdog_timeout=app.watchdog_timeout, + reload_command=app.reload_command, + restart_delay=app.restart_delay, + timer=app.timer, + daemon=app.daemon, + after=app.after if app.after else None, + before=app.before if app.before else None, + refresh_mode=app.refresh_mode, + stop_mode=app.stop_mode, + restart_condition=app.restart_condition, + install_mode=app.install_mode, + plugs=app.plugs, + aliases=app.aliases, + environment=app.environment, + adapter=app.adapter, + command_chain=["snap/command-chain/snapcraft-runner"], + sockets=app_sockets if app_sockets else None, + ) + + snap_metadata = SnapMetadata( + name=project.name, + title=project.title, + version=project.version, + summary=project.summary, + description=project.description, # type: ignore + license=project.license, + type=project.type, + architectures=[arch], + base=cast(str, project.base), + assumes=["command-chain"] if snap_apps else None, + epoch=project.epoch, + apps=snap_apps or None, + confinement=project.confinement, + grade=project.grade or "stable", + environment=project.environment, + plugs=project.plugs, + hooks=project.hooks, + layout=project.layout, + ) + + yaml.add_representer(str, _repr_str, Dumper=yaml.SafeDumper) + yaml_data = snap_metadata.yaml( + by_alias=True, + exclude_none=True, + allow_unicode=True, + sort_keys=False, + width=1000, + ) + + snap_yaml = meta_dir / "snap.yaml" + snap_yaml.write_text(yaml_data) + + +def _repr_str(dumper, data): + """Multi-line string representer for the YAML dumper.""" + if "\n" in data: + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) diff --git a/snapcraft/os_release.py b/snapcraft/os_release.py new file mode 100644 index 0000000000..cbf3bee9f9 --- /dev/null +++ b/snapcraft/os_release.py @@ -0,0 +1,93 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2017-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""OS release information helpers.""" + +import contextlib +from pathlib import Path +from typing import Dict + +from snapcraft import errors + +_ID_TO_UBUNTU_CODENAME = { + "17.10": "artful", + "17.04": "zesty", + "16.04": "xenial", + "14.04": "trusty", +} + + +class OsRelease: + """A class to intelligently determine the OS on which we're running.""" + + def __init__(self, *, os_release_file: Path = Path("/etc/os-release")) -> None: + """Create a new OsRelease instance. + + :param str os_release_file: Path to os-release file to be parsed. + """ + with contextlib.suppress(FileNotFoundError): + self._os_release = {} # type: Dict[str, str] + with os_release_file.open(encoding="utf-8") as release_file: + for line in release_file: + entry = line.rstrip().split("=") + if len(entry) == 2: + self._os_release[entry[0]] = entry[1].strip('"') + + def id(self) -> str: + """Return the OS ID. + + :raises SnapcraftError: If no ID can be determined. + """ + with contextlib.suppress(KeyError): + return self._os_release["ID"] + + raise errors.SnapcraftError("Unable to determine host OS ID") + + def name(self) -> str: + """Return the OS name. + + :raises SnapcraftError: If no name can be determined. + """ + with contextlib.suppress(KeyError): + return self._os_release["NAME"] + + raise errors.SnapcraftError("Unable to determine host OS name") + + def version_id(self) -> str: + """Return the OS version ID. + + :raises SnapcraftError: If no version ID can be determined. + """ + with contextlib.suppress(KeyError): + return self._os_release["VERSION_ID"] + + raise errors.SnapcraftError("Unable to determine host OS version ID") + + def version_codename(self) -> str: + """Return the OS version codename. + + This first tries to use the VERSION_CODENAME. If that's missing, it + tries to use the VERSION_ID to figure out the codename on its own. + + :raises SnapcraftError: If no version codename can be determined. + """ + with contextlib.suppress(KeyError): + return self._os_release["VERSION_CODENAME"] + + with contextlib.suppress(KeyError): + return _ID_TO_UBUNTU_CODENAME[self._os_release["VERSION_ID"]] + + raise errors.SnapcraftError("Unable to determine host OS version codename") diff --git a/snapcraft/pack.py b/snapcraft/pack.py new file mode 100644 index 0000000000..bc8807a1da --- /dev/null +++ b/snapcraft/pack.py @@ -0,0 +1,77 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snap file packing.""" + +import subprocess +from pathlib import Path +from typing import List, Optional, Union + +from craft_cli import emit + +from snapcraft import errors + + +def pack_snap( + directory: Path, *, output: Optional[str], compression: Optional[str] = None +) -> None: + """Pack snap contents. + + :param output: Snap file name or directory. + :param compression: Compression type to use, None for defaults. + """ + emit.trace(f"pack_snap: output={output!r}, compression={compression!r}") + + output_file = None + output_dir = None + + if output: + output_path = Path(output) + output_parent = output_path.parent + if output_path.is_dir(): + output_dir = str(output_path) + elif output_parent and output_parent != Path("."): + output_dir = str(output_parent) + output_file = output_path.name + else: + output_file = output + + command: List[Union[str, Path]] = ["snap", "pack"] + if output_file is not None: + command.extend(["--filename", output_file]) + + # When None, just use snap pack's default settings. + if compression is not None: + command.extend(["--compression", compression]) + + command.append(directory) + + if output_dir is not None: + command.append(output_dir) + + emit.progress("Creating snap package...") + emit.trace(f"Pack command: {command}") + try: + subprocess.run( + command, capture_output=True, check=True, universal_newlines=True + ) # type: ignore + except subprocess.CalledProcessError as err: + msg = f"Cannot pack snap file: {err!s}" + if err.stderr: + msg += f" ({err.stderr.strip()!s})" + raise errors.SnapcraftError(msg) + + emit.message("Created snap package", intermediate=True) diff --git a/snapcraft/parts/__init__.py b/snapcraft/parts/__init__.py new file mode 100644 index 0000000000..f7e2a9038d --- /dev/null +++ b/snapcraft/parts/__init__.py @@ -0,0 +1,21 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Parts lifecycle processing.""" + +from .parts import PartsLifecycle + +__all__ = ["PartsLifecycle"] diff --git a/snapcraft/parts/desktop_file.py b/snapcraft/parts/desktop_file.py new file mode 100644 index 0000000000..7db5bd085a --- /dev/null +++ b/snapcraft/parts/desktop_file.py @@ -0,0 +1,128 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2016-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Desktop file parser.""" + +import configparser +import os +import shlex +from pathlib import Path +from typing import Optional + +from craft_cli import emit + +from snapcraft import errors + + +class DesktopFile: + """Parse and process a .desktop file. + + :param snap_name: The snap package name. + :param app_name: The name of the app using the desktop file. + :param filename: The desktop file name. + :param prime_dir: The prime directory path. + + :raises DesktopFileError: If the desktop file does not exist. + """ + + def __init__( + self, *, snap_name: str, app_name: str, filename: str, prime_dir: Path + ) -> None: + self._snap_name = snap_name + self._app_name = app_name + self._filename = filename + self._prime_dir = prime_dir + + file_path = prime_dir / filename + if not file_path.is_file(): + raise errors.DesktopFileError( + filename, f"file does not exist (defined in app {app_name!r})" + ) + + self._parser = configparser.ConfigParser(interpolation=None) + # mypy type checking ignored, see https://github.com/python/mypy/issues/506 + self._parser.optionxform = str # type: ignore + self._parser.read(file_path, encoding="utf-8") + + def _parse_and_reformat_section_exec(self, section): + exec_value = self._parser[section]["Exec"] + exec_split = shlex.split(exec_value, posix=False) + + # Ensure command is invoked correctly. + if self._app_name == self._snap_name: + exec_split[0] = self._app_name + else: + exec_split[0] = f"{self._snap_name}.{self._app_name}" + + self._parser[section]["Exec"] = " ".join(exec_split) + + def _parse_and_reformat_section(self, *, section, icon_path: Optional[str] = None): + if "Exec" not in self._parser[section]: + raise errors.DesktopFileError(self._filename, "missing 'Exec' key") + + self._parse_and_reformat_section_exec(section) + + if "Icon" in self._parser[section]: + icon = self._parser[section]["Icon"] + + if icon_path is not None: + icon = icon_path + + # Strip any leading slash. + icon = icon[1:] if icon.startswith("/") else icon + + # Strip any leading ${SNAP}. + icon = icon[8:] if icon.startswith("${SNAP}") else icon + + # With everything stripped, check to see if the icon is there. + # if it is, add "${SNAP}" back and set the icon + if (self._prime_dir / icon).is_file(): + self._parser[section]["Icon"] = os.path.join("${SNAP}", icon) + else: + emit.message( + f"Icon {icon!r} specified in desktop file {self._filename!r} " + f"not found in prime directory." + ) + + def _parse_and_reformat(self, *, icon_path: Optional[str] = None) -> None: + if "Desktop Entry" not in self._parser.sections(): + raise errors.DesktopFileError( + self._filename, "missing 'Desktop Entry' section" + ) + + for section in self._parser.sections(): + self._parse_and_reformat_section(section=section, icon_path=icon_path) + + def write(self, *, gui_dir: Path, icon_path: Optional[str] = None) -> None: + """Write the desktop file. + + :param gui_dir: The desktop file destination directory. + :param icon_path: The icon corresponding to this desktop file. + """ + self._parse_and_reformat(icon_path=icon_path) + + gui_dir.mkdir(parents=True, exist_ok=True) + + # Rename the desktop file to match the app name. This will help + # unity8 associate them (https://launchpad.net/bugs/1659330). + target = gui_dir / f"{self._app_name}.desktop" + + if target.exists(): + # Unlikely. A desktop file in meta/gui/ already existed for + # this app. Let's pretend it wasn't there and overwrite it. + target.unlink() + with target.open("w", encoding="utf-8") as target_file: + self._parser.write(target_file, space_around_delimiters=False) diff --git a/snapcraft/parts/grammar.py b/snapcraft/parts/grammar.py new file mode 100644 index 0000000000..af20e61abf --- /dev/null +++ b/snapcraft/parts/grammar.py @@ -0,0 +1,79 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Grammar processor.""" + +from typing import Any, Dict + +from craft_grammar import GrammarProcessor + +_KEYS = [ + "source", + "build-environment", + "build-packages", + "stage-packages", + "build-snaps", + "stage-snaps", +] + +_SCALAR_VALUES = ["source"] + + +def process_part( + *, part_yaml_data: Dict[str, Any], processor: GrammarProcessor +) -> Dict[str, Any]: + """Process grammar for a given part.""" + existing_keys = (key for key in _KEYS if key in part_yaml_data) + + for key in existing_keys: + unprocessed_grammar = part_yaml_data[key] + + if key in _SCALAR_VALUES and isinstance(unprocessed_grammar, str): + unprocessed_grammar = [unprocessed_grammar] + + processed_grammar = processor.process(grammar=unprocessed_grammar) + + if key in _SCALAR_VALUES and isinstance(processed_grammar, list): + if processed_grammar: + processed_grammar = processed_grammar[0] + else: + processed_grammar = None + part_yaml_data[key] = processed_grammar + + return part_yaml_data + + +def process_parts( + *, parts_yaml_data: Dict[str, Any], arch: str, target_arch: str +) -> Dict[str, Any]: + """Process grammar for parts. + + :param yaml_data: unprocessed snapcraft.yaml. + :returns: process snapcraft.yaml. + """ + # TODO: make checker optional in craft-grammar. + processor = GrammarProcessor( + arch=arch, + target_arch=target_arch, + checker=lambda x: x == x, # pylint: disable=comparison-with-itself + ) + + for part_name in parts_yaml_data: + parts_yaml_data[part_name] = process_part( + part_yaml_data=parts_yaml_data[part_name], processor=processor + ) + + return parts_yaml_data diff --git a/snapcraft/parts/lifecycle.py b/snapcraft/parts/lifecycle.py new file mode 100644 index 0000000000..18694de309 --- /dev/null +++ b/snapcraft/parts/lifecycle.py @@ -0,0 +1,333 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Parts lifecycle preparation and execution.""" + +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from craft_cli import EmitterMode, emit +from craft_parts import infos + +from snapcraft import errors, extensions, pack, providers, utils +from snapcraft.meta import snap_yaml +from snapcraft.projects import GrammarAwareProject, Project +from snapcraft.providers import capture_logs_from_instance + +from . import PartsLifecycle, grammar, plugins, yaml_utils +from .setup_assets import setup_assets +from .update_metadata import update_project_metadata + +if TYPE_CHECKING: + import argparse + + +@dataclass +class _SnapProject: + project_file: Path + assets_dir: Path = Path("snap") + + +_SNAP_PROJECT_FILES = [ + _SnapProject(project_file=Path("snapcraft.yaml")), + _SnapProject(project_file=Path("snap/snapcraft.yaml")), + _SnapProject( + project_file=Path("build-aux/snap/snapcraft.yaml"), + assets_dir=Path("build-aux/snap"), + ), + _SnapProject(project_file=Path(".snapcraft.yaml")), +] + + +def get_snap_project() -> _SnapProject: + """Find the snapcraft.yaml to load. + + :raises SnapcraftError: if the project yaml file cannot be found. + """ + for snap_project in _SNAP_PROJECT_FILES: + if snap_project.project_file.exists(): + return snap_project + + raise errors.SnapcraftError( + "Could not find snap/snapcraft.yaml. Are you sure you are in the " + "right directory?", + resolution="To start a new project, use `snapcraft init`", + ) + + +def process_yaml(project_file: Path) -> Dict[str, Any]: + """Process the yaml from project file. + + :raises SnapcraftError: if the project yaml file cannot be loaded. + """ + yaml_data = {} + + try: + with open(project_file, encoding="utf-8") as yaml_file: + yaml_data = yaml_utils.load(yaml_file) + except OSError as err: + msg = err.strerror + if err.filename: + msg = f"{msg}: {err.filename!r}." + raise errors.SnapcraftError(msg) from err + + # validate project grammar + GrammarAwareProject.validate_grammar(yaml_data) + + # TODO: support for target_arch + arch = _get_arch() + yaml_data = extensions.apply_extensions(yaml_data, arch=arch, target_arch=arch) + + if "parts" in yaml_data: + yaml_data["parts"] = grammar.process_parts( + parts_yaml_data=yaml_data["parts"], arch=arch, target_arch=arch + ) + + return yaml_data + + +def _extract_parse_info(yaml_data: Dict[str, Any]) -> Dict[str, List[str]]: + """Remove parse-info data from parts. + + :param yaml_data: The project YAML data. + + :return: The extracted parse info for each part. + """ + parse_info: Dict[str, List[str]] = {} + + if "parts" in yaml_data: + for name, data in yaml_data["parts"].items(): + if "parse-info" in data: + parse_info[name] = data.pop("parse-info") + + return parse_info + + +def run(command_name: str, parsed_args: "argparse.Namespace") -> None: + """Run the parts lifecycle. + + :raises SnapcraftError: if the step name is invalid, or the project + yaml file cannot be loaded. + :raises LegacyFallback: if the project's base is not core22. + """ + emit.trace(f"command: {command_name}, arguments: {parsed_args}") + + snap_project = get_snap_project() + yaml_data = process_yaml(snap_project.project_file) + parse_info = _extract_parse_info(yaml_data) + + if parsed_args.provider: + raise errors.SnapcraftError("Option --provider is not supported.") + + # Register our own plugins + plugins.register() + + project = Project.unmarshal(yaml_data) + + _run_command( + command_name, + project=project, + parse_info=parse_info, + assets_dir=snap_project.assets_dir, + parsed_args=parsed_args, + ) + + +def _run_command( + command_name: str, + *, + project: Project, + parse_info: Dict[str, List[str]], + assets_dir: Path, + parsed_args: "argparse.Namespace", +) -> None: + managed_mode = utils.is_managed_mode() + part_names = getattr(parsed_args, "parts", None) + + if not managed_mode and command_name == "snap": + emit.message( + "The 'snap' command is deprecated, use 'pack' instead.", intermediate=True + ) + + if parsed_args.use_lxd and providers.get_platform_default_provider() == "lxd": + emit.message("LXD is used by default on this platform.", intermediate=True) + + if ( + not managed_mode + and not parsed_args.destructive_mode + and not os.getenv("SNAPCRAFT_BUILD_ENVIRONMENT") == "host" + ): + if command_name == "clean" and not part_names: + _clean_provider(project, parsed_args) + else: + _run_in_provider(project, command_name, parsed_args) + return + + if managed_mode: + work_dir = utils.get_managed_environment_home_path() + else: + work_dir = Path.cwd() + + step_name = "prime" if command_name in ("pack", "snap") else command_name + + lifecycle = PartsLifecycle( + project.parts, + work_dir=work_dir, + assets_dir=assets_dir, + package_repositories=project.package_repositories, + part_names=part_names, + adopt_info=project.adopt_info, + project_name=project.name, + parse_info=parse_info, + project_vars={ + "version": project.version or "", + "grade": project.grade or "", + }, + extra_build_snaps=_get_extra_build_snaps(project), + ) + if command_name == "clean": + lifecycle.clean(part_names=part_names) + return + + lifecycle.run( + step_name, + debug=parsed_args.debug, + shell=getattr(parsed_args, "shell", False), + shell_after=getattr(parsed_args, "shell_after", False), + ) + + # Extract metadata and generate snap.yaml + project_vars = lifecycle.project_vars + if step_name == "prime" and not part_names: + emit.progress("Extracting and updating metadata...") + metadata_list = lifecycle.extract_metadata() + update_project_metadata( + project, + project_vars=project_vars, + metadata_list=metadata_list, + assets_dir=assets_dir, + prime_dir=lifecycle.prime_dir, + ) + + emit.progress("Copying snap assets...") + setup_assets( + project, + assets_dir=assets_dir, + prime_dir=lifecycle.prime_dir, + ) + + emit.progress("Generating snap metadata...") + snap_yaml.write( + project, + lifecycle.prime_dir, + arch=lifecycle.target_arch, + ) + emit.message("Generated snap metadata", intermediate=True) + + if command_name in ("pack", "snap"): + pack.pack_snap( + lifecycle.prime_dir, + output=parsed_args.output, + compression=project.compression, + ) + + +def _clean_provider(project: Project, parsed_args: "argparse.Namespace") -> None: + """Clean the provider environment. + + :param project: The project to clean. + """ + emit.trace("Clean build provider") + provider_name = "lxd" if parsed_args.use_lxd else None + provider = providers.get_provider(provider_name) + instance_names = provider.clean_project_environments( + project_name=project.name, project_path=Path().absolute() + ) + if instance_names: + emit.message(f"Removed instance: {', '.join(instance_names)}") + else: + emit.message("No instances to remove") + + +def _run_in_provider( + project: Project, command_name: str, parsed_args: "argparse.Namespace" +) -> None: + """Pack image in provider instance.""" + emit.trace("Checking build provider availability") + provider_name = "lxd" if parsed_args.use_lxd else None + provider = providers.get_provider(provider_name) + provider.ensure_provider_is_available() + + cmd = ["snapcraft", command_name] + + if hasattr(parsed_args, "parts"): + cmd.extend(parsed_args.parts) + + if getattr(parsed_args, "output", None): + cmd.extend(["--output", parsed_args.output]) + + if emit.get_mode() == EmitterMode.VERBOSE: + cmd.append("--verbose") + elif emit.get_mode() == EmitterMode.QUIET: + cmd.append("--quiet") + elif emit.get_mode() == EmitterMode.TRACE: + cmd.append("--trace") + + if parsed_args.debug: + cmd.append("--debug") + if getattr(parsed_args, "shell", False): + cmd.append("--shell") + if getattr(parsed_args, "shell_after", False): + cmd.append("--shell-after") + + output_dir = utils.get_managed_environment_project_path() + + emit.progress("Launching instance...") + with provider.launched_environment( + project_name=project.name, + project_path=Path().absolute(), + base=project.get_effective_base(), + ) as instance: + try: + with emit.pause(): + instance.execute_run(cmd, check=True, cwd=output_dir) + capture_logs_from_instance(instance) + except subprocess.CalledProcessError as err: + capture_logs_from_instance(instance) + raise providers.ProviderError( + f"Failed to execute {command_name} in instance." + ) from err + + +# TODO Needs exposure from craft-parts. +def _get_arch() -> str: + machine = infos._get_host_architecture() # pylint: disable=protected-access + # FIXME Raise the potential KeyError. + return infos._ARCH_TRANSLATIONS[machine]["deb"] # pylint: disable=protected-access + + +def _get_extra_build_snaps(project: Project) -> Optional[List[str]]: + """Get list of extra snaps required to build.""" + extra_build_snaps = project.get_content_snaps() + if project.base is not None: + if extra_build_snaps is None: + extra_build_snaps = [project.base] + else: + extra_build_snaps.append(project.base) + return extra_build_snaps diff --git a/snapcraft/parts/parts.py b/snapcraft/parts/parts.py new file mode 100644 index 0000000000..2344fcc74d --- /dev/null +++ b/snapcraft/parts/parts.py @@ -0,0 +1,280 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Craft-parts lifecycle wrapper.""" + +import pathlib +import subprocess +from typing import Any, Dict, List, Optional + +import craft_parts +from craft_cli import emit +from craft_parts import ActionType, Part, Step +from xdg import BaseDirectory # type: ignore + +from snapcraft import errors, repo +from snapcraft.meta import ExtractedMetadata, extract_metadata + +_LIFECYCLE_STEPS = { + "pull": Step.PULL, + "overlay": Step.OVERLAY, + "build": Step.BUILD, + "stage": Step.STAGE, + "prime": Step.PRIME, +} + + +class PartsLifecycle: + """Create and manage the parts lifecycle. + + :param all_parts: A dictionary containing the parts defined in the project. + :param work_dir: The working directory for parts processing. + :param assets_dir: The directory containing project assets. + :param adopt_info: The name of the part containing metadata do adopt. + :param extra_build_snaps: A list of additional build snaps to install. + + :raises PartsLifecycleError: On error initializing the parts lifecycle. + """ + + def __init__( + self, + all_parts: Dict[str, Any], + *, + work_dir: pathlib.Path, + assets_dir: pathlib.Path, + package_repositories: List[Dict[str, Any]], + part_names: Optional[List[str]], + adopt_info: Optional[str], + parse_info: Dict[str, List[str]], + project_name: str, + project_vars: Dict[str, str], + extra_build_snaps: Optional[List[str]] = None, + ): + self._assets_dir = assets_dir + self._package_repositories = package_repositories + self._part_names = part_names + self._adopt_info = adopt_info + self._parse_info = parse_info + + emit.progress("Initializing parts lifecycle") + + # set the cache dir for parts package management + cache_dir = BaseDirectory.save_cache_path("snapcraft") + + extra_build_packages = [] + if self._package_repositories: + # Install pre-requisite packages for apt-key, if not installed. + # FIXME: package names should be plataform-specific + extra_build_packages.extend(["gnupg", "dirmngr"]) + + try: + self._lcm = craft_parts.LifecycleManager( + {"parts": all_parts}, + application_name="snapcraft", + work_dir=work_dir, + cache_dir=cache_dir, + ignore_local_sources=["*.snap"], + extra_build_packages=extra_build_packages, + extra_build_snaps=extra_build_snaps, + project_name=project_name, + project_vars_part_name=adopt_info, + project_vars=project_vars, + ) + except craft_parts.PartsError as err: + raise errors.PartsLifecycleError(str(err)) from err + + @property + def prime_dir(self) -> pathlib.Path: + """Return the parts prime directory path.""" + return self._lcm.project_info.prime_dir + + @property + def target_arch(self) -> str: + """Return the parts project target architecture.""" + return self._lcm.project_info.target_arch + + @property + def project_vars(self) -> Dict[str, str]: + """Return the value of project variable ``version``.""" + return { + "version": self._lcm.project_info.get_project_var("version"), + "grade": self._lcm.project_info.get_project_var("grade"), + } + + def run( + self, + step_name: str, + *, + debug: bool = False, + shell: bool = False, + shell_after: bool = False, + ) -> None: + """Run the parts lifecycle. + + :param target_step: The final step to execute. + + :raises PartsLifecycleError: On error during lifecycle. + :raises RuntimeError: On unexpected error. + """ + target_step = _LIFECYCLE_STEPS.get(step_name) + if not target_step: + raise RuntimeError(f"Invalid target step {step_name!r}") + + if shell: + # convert shell to shell_after for the previous step + previous_steps = target_step.previous_steps() + target_step = previous_steps[-1] if previous_steps else None + shell_after = True + + try: + if target_step: + actions = self._lcm.plan(target_step, part_names=self._part_names) + else: + actions = [] + + self._install_package_repositories() + + emit.progress("Executing parts lifecycle...") + + with self._lcm.action_executor() as aex: + for action in actions: + message = _action_message(action) + emit.progress(f"Executing parts lifecycle: {message}") + with emit.open_stream("Executing action") as stream: + aex.execute(action, stdout=stream, stderr=stream) + emit.message(f"Executed: {message}", intermediate=True) + + if shell_after: + _launch_shell() + + emit.message("Executed parts lifecycle", intermediate=True) + except RuntimeError as err: + raise RuntimeError(f"Parts processing internal error: {err}") from err + except OSError as err: + if debug: + _launch_shell() + msg = err.strerror + if err.filename: + msg = f"{err.filename}: {msg}" + raise errors.PartsLifecycleError(msg) from err + except Exception as err: + if debug: + _launch_shell() + raise errors.PartsLifecycleError(str(err)) from err + + def _install_package_repositories(self): + emit.progress("Installing package repositories...") + if self._package_repositories: + refresh_required = repo.install( + self._package_repositories, key_assets=self._assets_dir / "keys" + ) + if refresh_required: + self._lcm.refresh_packages_list() + emit.message("Installed package repositories", intermediate=True) + + def clean(self, *, part_names: Optional[List[str]] = None) -> None: + """Remove lifecycle artifacts. + + :param part_names: The names of the parts to clean. If not + specified, all parts will be cleaned. + """ + if part_names: + message = "Cleaning parts: " + ", ".join(part_names) + else: + message = "Cleaning all parts" + + emit.message(message, intermediate=True) + self._lcm.clean(part_names=part_names) + + def extract_metadata(self) -> List[ExtractedMetadata]: + """Obtain metadata information.""" + if self._adopt_info is None or self._adopt_info not in self._parse_info: + return [] + + part = Part(self._adopt_info, {}) + locations = ( + part.part_src_dir, + part.part_build_dir, + part.part_install_dir, + ) + metadata_list: List[ExtractedMetadata] = [] + + for metadata_file in self._parse_info[self._adopt_info]: + emit.trace(f"extract metadata: parse info from {metadata_file}") + + for location in locations: + if pathlib.Path(location, metadata_file.lstrip("/")).is_file(): + metadata = extract_metadata(metadata_file, workdir=str(location)) + if metadata: + metadata_list.append(metadata) + break + + emit.message( + f"No metadata extracted from {metadata_file}", intermediate=True + ) + + return metadata_list + + +def _launch_shell(*, cwd: Optional[pathlib.Path] = None) -> None: + """Launch a user shell for debugging environment. + + :param cwd: Working directory to start user in. + """ + emit.message("Launching shell on build environment...", intermediate=True) + with emit.pause(): + subprocess.run(["bash"], check=False, cwd=cwd) + + +def _action_message(action: craft_parts.Action) -> str: + msg = { + Step.PULL: { + ActionType.RUN: "pull", + ActionType.RERUN: "repull", + ActionType.SKIP: "skip pull", + ActionType.UPDATE: "update sources for", + }, + Step.OVERLAY: { + ActionType.RUN: "overlay", + ActionType.RERUN: "re-overlay", + ActionType.SKIP: "skip overlay", + ActionType.UPDATE: "update overlay for", + ActionType.REAPPLY: "reapply", + }, + Step.BUILD: { + ActionType.RUN: "build", + ActionType.RERUN: "rebuild", + ActionType.SKIP: "skip build", + ActionType.UPDATE: "update build for", + }, + Step.STAGE: { + ActionType.RUN: "stage", + ActionType.RERUN: "restage", + ActionType.SKIP: "skip stage", + }, + Step.PRIME: { + ActionType.RUN: "prime", + ActionType.RERUN: "re-prime", + ActionType.SKIP: "skip prime", + }, + } + + message = f"{msg[action.step][action.action_type]} {action.part_name}" + + if action.reason: + message += f" ({action.reason})" + + return message diff --git a/snapcraft/parts/plugins/__init__.py b/snapcraft/parts/plugins/__init__.py new file mode 100644 index 0000000000..394653407e --- /dev/null +++ b/snapcraft/parts/plugins/__init__.py @@ -0,0 +1,23 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft specific plugins.""" + + +from .conda_plugin import CondaPlugin +from .register import register + +__all__ = ["CondaPlugin", "register"] diff --git a/snapcraft/parts/plugins/conda_plugin.py b/snapcraft/parts/plugins/conda_plugin.py new file mode 100644 index 0000000000..47cb0f1c12 --- /dev/null +++ b/snapcraft/parts/plugins/conda_plugin.py @@ -0,0 +1,159 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""The conda plugin.""" + +import os +import platform +import textwrap +from typing import Any, Dict, List, Optional, Set, cast + +from craft_parts import plugins +from overrides import overrides + +from snapcraft import errors + +_MINICONDA_ARCH_FROM_SNAP_ARCH = { + "i386": "x86", + "amd64": "x86_64", + "armhf": "armv7l", + "ppc64el": "ppc64le", +} +_MINICONDA_ARCH_FROM_PLATFORM = {"x86_64": {"32bit": "x86", "64bit": "x86_64"}} + + +def _get_architecture() -> str: + snap_arch = os.getenv("SNAP_ARCH") + # The first scenario is the general case as snapcraft will be running from the snap. + if snap_arch is not None: + try: + miniconda_arch = _MINICONDA_ARCH_FROM_SNAP_ARCH[snap_arch] + except KeyError as key_error: + raise errors.SnapcraftError( + f"Architecture not supported for conda plugin: {snap_arch!r}" + ) from key_error + # But there may be times when running from a virtualenv while doing development. + else: + machine = platform.machine() + architecture = platform.architecture()[0] + miniconda_arch = _MINICONDA_ARCH_FROM_PLATFORM[machine][architecture] + + return miniconda_arch + + +def _get_miniconda_source(version: str) -> str: + """Return tuple of source_url and source_checksum (if known).""" + arch = _get_architecture() + source = f"https://repo.anaconda.com/miniconda/Miniconda3-{version}-Linux-{arch}.sh" + return source + + +class CondaPluginProperties(plugins.PluginProperties, plugins.PluginModel): + """The part properties used by the conda plugin.""" + + # part properties required by the plugin + conda_packages: Optional[List[str]] = None + conda_python_version: Optional[str] = None + conda_miniconda_version: str = "latest" + + @classmethod + def unmarshal(cls, data: Dict[str, Any]) -> "CondaPluginProperties": + """Populate class attributes from the part specification. + + :param data: A dictionary containing part properties. + + :return: The populated plugin properties data object. + + :raise pydantic.ValidationError: If validation fails. + """ + plugin_data = plugins.extract_plugin_properties( + data, + plugin_name="conda", + ) + return cls(**plugin_data) + + +class CondaPlugin(plugins.Plugin): + """A plugin for conda projects. + + This plugin uses the common plugin keywords as well as those for "sources". + For more information check the 'plugins' topic for the former and the + 'sources' topic for the latter. + + Additionally, this plugin uses the following plugin-specific keywords: + - conda-packages + (list of packages, default: None) + List of packages for conda to install. + - conda-python-version + (str, default: None) + Python version for conda to use (i.e. "3.9"). + - conda-miniconda-version + (str, default: latest) + The version of miniconda to initialize. + """ + + properties_class = CondaPluginProperties + + @overrides + def get_build_snaps(self) -> Set[str]: + return set() + + @overrides + def get_build_packages(self) -> Set[str]: + return set() + + @overrides + def get_build_environment(self) -> Dict[str, str]: + return {"PATH": "${HOME}/miniconda/bin:${PATH}"} + + @staticmethod + def _get_download_miniconda_command(url: str) -> str: + return textwrap.dedent( + f"""\ + if ! [ -e "${{HOME}}/miniconda.sh" ]; then + curl --proto '=https' --tlsv1.2 -sSf {url} > ${{HOME}}/miniconda.sh + chmod 755 ${{HOME}}/miniconda.sh + fi""" + ) + + def _get_deploy_command(self, options) -> str: + conda_target_prefix = f"/snap/{self._part_info.project_name}/current" + + deploy_cmd = [ + f"CONDA_TARGET_PREFIX_OVERRIDE={conda_target_prefix}", + "conda", + "create", + "--prefix", + str(self._part_info.part_install_dir), + "--yes", + ] + if options.conda_python_version: + deploy_cmd.append(f"python={options.conda_python_version}") + + if options.conda_packages: + deploy_cmd.extend(options.conda_packages) + + return " ".join(deploy_cmd) + + @overrides + def get_build_commands(self) -> List[str]: + options = cast(CondaPluginProperties, self._options) + url = _get_miniconda_source(options.conda_miniconda_version) + return [ + self._get_download_miniconda_command(url), + "${HOME}/miniconda.sh -bfp ${HOME}/miniconda", + self._get_deploy_command(options), + ] diff --git a/snapcraft/parts/plugins/register.py b/snapcraft/parts/plugins/register.py new file mode 100644 index 0000000000..a778cbd440 --- /dev/null +++ b/snapcraft/parts/plugins/register.py @@ -0,0 +1,26 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft provided plugin registration.""" + +import craft_parts + +from .conda_plugin import CondaPlugin + + +def register() -> None: + """Register Snapcraft plugins.""" + craft_parts.plugins.register({"conda": CondaPlugin}) diff --git a/snapcraft/parts/setup_assets.py b/snapcraft/parts/setup_assets.py new file mode 100644 index 0000000000..02c1d7f453 --- /dev/null +++ b/snapcraft/parts/setup_assets.py @@ -0,0 +1,215 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Copy assets to their final locations.""" + +import itertools +import os +import shutil +import stat +import textwrap +import urllib.parse +from pathlib import Path +from typing import List, Optional + +import requests +from craft_cli import emit + +from snapcraft import errors +from snapcraft.projects import Project + +from .desktop_file import DesktopFile + + +def setup_assets(project: Project, *, assets_dir: Path, prime_dir: Path) -> None: + """Copy gui assets to the appropriate location in the snap filesystem. + + :param project: The snap project file. + :param assets_dir: The directory containing snap project assets. + :param prime_dir: The directory containing the content to be snapped. + """ + meta_dir = prime_dir / "meta" + gui_dir = meta_dir / "gui" + gui_dir.mkdir(parents=True, exist_ok=True) + + _write_snap_directory(assets_dir=assets_dir, prime_dir=prime_dir, meta_dir=meta_dir) + _write_snapcraft_runner(prime_dir=prime_dir) + # TODO: write snapcraft + + if not project.apps: + return + + icon_path = _finalize_icon( + project.icon, assets_dir=assets_dir, gui_dir=gui_dir, prime_dir=prime_dir + ) + relative_icon_path: Optional[str] = None + + if icon_path is not None: + if prime_dir in icon_path.parents: + icon_path = icon_path.relative_to(prime_dir) + relative_icon_path = str(icon_path) + + for app_name, app in project.apps.items(): + if not app.desktop: + continue + + desktop_file = DesktopFile( + snap_name=project.name, + app_name=app_name, + filename=app.desktop, + prime_dir=prime_dir, + ) + desktop_file.write(gui_dir=gui_dir, icon_path=relative_icon_path) + + _validate_command_chain( + app.command_chain, app_name=app_name, prime_dir=prime_dir + ) + + # TODO: copy gadget and kernel assets + + +def _finalize_icon( + icon: Optional[str], *, assets_dir: Path, gui_dir: Path, prime_dir: Path +) -> Optional[Path]: + """Ensure sure icon is properly configured and installed. + + Fetch from a remote URL, if required, and place in the meta/gui + directory. + """ + # Nothing to do if no icon is configured, search for existing icon. + if icon is None: + return _find_icon_file(assets_dir) + + # Extracted appstream icon paths will either: + # (1) point to a file relative to prime + # (2) point to a remote http(s) url + # + # The 'icon' specified in the snapcraft.yaml has the same + # constraint as (2) and would have already been validated + # as existing by the schema. So we can treat it the same + # at this point, regardless of the source of the icon. + parsed_url = urllib.parse.urlparse(icon) + parsed_path = Path(parsed_url.path) + icon_ext = parsed_path.suffix[1:] + target_icon_path = Path(gui_dir, f"icon.{icon_ext}") + + target_icon_path.parent.mkdir(parents=True, exist_ok=True) + if parsed_url.scheme in ["http", "https"]: + # Remote - fetch URL and write to target. + emit.progress(f"Fetching icon from {icon!r}") + icon_data = requests.get(icon).content + target_icon_path.write_bytes(icon_data) + elif parsed_url.scheme == "": + source_path = Path( + prime_dir, + parsed_path.relative_to("/") if parsed_path.is_absolute() else parsed_path, + ) + if source_path.exists(): + # Local with path relative to prime. + shutil.copy(source_path, target_icon_path) + elif parsed_path.exists(): + # Local with path relative to project. + shutil.copy(parsed_path, target_icon_path) + else: + # No icon found, fall back to searching for existing icon. + return _find_icon_file(assets_dir) + else: + raise RuntimeError(f"Unexpected icon path: {parsed_url!r}") + + return target_icon_path + + +def _find_icon_file(assets_dir: Path) -> Optional[Path]: + for icon_path in (assets_dir / "gui/icon.png", assets_dir / "gui/icon.svg"): + if icon_path.is_file(): + return icon_path + return None + + +def _validate_command_chain( + command_chain: List[str], *, app_name: str, prime_dir: Path +) -> None: + """Verify if each item in the command chain is executble.""" + for item in command_chain: + executable_path = prime_dir / item + + # command-chain entries must always be relative to the root of + # the snap, i.e. PATH is not used. + if not _is_executable(executable_path): + raise errors.SnapcraftError( + f"Failed to generate snap metadata: The command-chain item {item!r} " + f"defined in the app {app_name!r} does not exist or is not executable.", + resolution=f"Ensure that {item!r} is relative to the prime directory.", + ) + + +def _is_executable(path: Path) -> bool: + """Verify if the given path corresponds to an executable file.""" + if not path.is_file(): + return False + + mode = path.stat().st_mode + return bool(mode & stat.S_IXUSR or mode & stat.S_IXGRP or mode & stat.S_IXOTH) + + +def _write_snapcraft_runner(*, prime_dir: Path): + content = textwrap.dedent( + """#!/bin/sh + export PATH="$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH" + export LD_LIBRARY_PATH="$SNAP_LIBRARY_PATH:$LD_LIBRARY_PATH" + exec "$@" + """ + ) + + runner_path = prime_dir / "snap/command-chain/snapcraft-runner" + runner_path.parent.mkdir(parents=True, exist_ok=True) + runner_path.write_text(content) + runner_path.chmod(0o755) + + +def _write_snap_directory(*, assets_dir: Path, prime_dir: Path, meta_dir: Path) -> None: + """Record manifest and copy assets found under the assets directory. + + These assets have priority over any code generated assets and include: + - hooks + - gui + """ + prime_snap_dir = prime_dir / "snap" + + snap_dir_iter = itertools.product([prime_snap_dir], ["hooks", "gui"]) + meta_dir_iter = itertools.product([meta_dir], ["hooks", "gui"]) + + for origin in itertools.chain(snap_dir_iter, meta_dir_iter): + src_dir = assets_dir / origin[1] + dst_dir = origin[0] / origin[1] + + if src_dir.is_dir(): + dst_dir.mkdir(parents=True, exist_ok=True) + for asset in os.listdir(src_dir): + source = src_dir / asset + destination = dst_dir / asset + + destination.unlink(missing_ok=True) + + shutil.copy(source, destination, follow_symlinks=True) + + # Ensure that the hook is executable in meta/hooks, this is a moot + # point considering the prior link_or_copy call, but is technically + # correct and allows for this operation to take place only once. + if origin[0] == meta_dir and origin[1] == "hooks": + destination.chmod(0o755) + + # TODO: record manifest and source snapcraft.yaml diff --git a/snapcraft/parts/update_metadata.py b/snapcraft/parts/update_metadata.py new file mode 100644 index 0000000000..ef781ee8d9 --- /dev/null +++ b/snapcraft/parts/update_metadata.py @@ -0,0 +1,167 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""External metadata helpers.""" + +from pathlib import Path +from typing import Dict, Final, List + +import pydantic +from craft_cli import emit + +from snapcraft import errors +from snapcraft.meta import ExtractedMetadata +from snapcraft.projects import MANDATORY_ADOPTABLE_FIELDS, Project + +_VALID_ICON_EXTENSIONS: Final[List[str]] = ["png", "svg"] + + +def update_project_metadata( + project: Project, + *, + project_vars: Dict[str, str], + metadata_list: List[ExtractedMetadata], + assets_dir: Path, + prime_dir: Path, +) -> None: + """Set project fields using corresponding adopted entries. + + Fields are validated on assignment by pydantic. + + :param project: The project to update. + :param project_vars: The variables updated during lifecycle execution. + :param metadata_list: List containing parsed information from metadata files. + + :raises SnapcraftError: If project update failed. + """ + _update_project_variables(project, project_vars) + + for metadata in metadata_list: + # Data specified in the project yaml has precedence over extracted data + if metadata.title and not project.title: + project.title = metadata.title + + if metadata.summary and not project.summary: + project.summary = metadata.summary + + if metadata.description and not project.description: + project.description = metadata.description + + if metadata.version and not project.version: + project.version = metadata.version + + if metadata.grade and not project.grade: + project.grade = metadata.grade # type: ignore + + if not project.icon: + _update_project_icon( + project, metadata=metadata, assets_dir=assets_dir, prime_dir=prime_dir + ) + + _update_project_app_desktop_file( + project, metadata=metadata, assets_dir=assets_dir, prime_dir=prime_dir + ) + + # Fields that must not end empty + for field in MANDATORY_ADOPTABLE_FIELDS: + if not getattr(project, field): + raise errors.SnapcraftError( + f"Field {field!r} was not adopted from metadata" + ) + + +def _update_project_variables(project: Project, project_vars: Dict[str, str]): + """Update project fields with values set during lifecycle processing.""" + try: + if project_vars["version"]: + project.version = project_vars["version"] + if project_vars["grade"]: + project.grade = project_vars["grade"] # type: ignore + except pydantic.ValidationError as err: + _raise_formatted_validation_error(err) + raise errors.SnapcraftError(f"error setting variable: {err}") + + +def _update_project_icon( + project: Project, *, metadata: ExtractedMetadata, assets_dir: Path, prime_dir: Path +) -> None: + """Look for icons files and update project. + + Existing icon in snap/gui/icon.{png,svg} has precedence over extracted data + """ + icon_files = (f"{assets_dir}/gui/icon.{ext}" for ext in _VALID_ICON_EXTENSIONS) + + for icon_file in icon_files: + if Path(icon_file).is_file(): + break + else: + if metadata.icon and Path(prime_dir, metadata.icon.lstrip("/")).is_file(): + project.icon = metadata.icon + + +def _update_project_app_desktop_file( + project: Project, *, metadata: ExtractedMetadata, assets_dir: Path, prime_dir: Path +) -> None: + """Look for desktop files and update project. + + Existing desktop file snap/gui/.desktop has precedence over extracted data + """ + if metadata.common_id and project.apps: + app_name = None + for name, data in project.apps.items(): + if data.common_id == metadata.common_id: + app_name = name + break + + if not app_name: + emit.trace(f"no app declares id {metadata.common_id!r}") + return + + if project.apps[app_name].desktop: + emit.trace("app {app_name!r} already declares a desktop file") + return + + emit.trace( + f"look for desktop file with id {metadata.common_id!r} in app {app_name!r}" + ) + + desktop_file = f"{assets_dir}/gui/{app_name}.desktop" + if Path(desktop_file).is_file(): + emit.trace(f"use already existing desktop file {desktop_file!r}") + return + + if metadata.desktop_file_paths: + for filename in metadata.desktop_file_paths: + if Path(prime_dir, filename.lstrip("/")).is_file(): + project.apps[app_name].desktop = filename + emit.trace(f"use desktop file {filename!r}") + break + + +def _raise_formatted_validation_error(err: pydantic.ValidationError): + error_list = err.errors() + if len(error_list) != 1: + return + + error = error_list[0] + loc = error.get("loc") + msg = error.get("msg") + + if not (loc and msg) or not isinstance(loc, tuple): + return + + varname = ".".join((x for x in loc if isinstance(x, str))) + raise errors.SnapcraftError(f"error setting {varname}: {msg}") diff --git a/snapcraft/parts/validation.py b/snapcraft/parts/validation.py new file mode 100644 index 0000000000..f3a6921ae6 --- /dev/null +++ b/snapcraft/parts/validation.py @@ -0,0 +1,47 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Part schema validation.""" + +from typing import Any, Dict + +from craft_parts import plugins +from craft_parts.parts import PartSpec + + +def validate_part(data: Dict[str, Any]) -> None: + """Validate the given part data against common and plugin models. + + :param data: The part data to validate. + """ + if not isinstance(data, dict): + raise TypeError("value must be a dictionary") + + # copy the original data, we'll modify it + spec = data.copy() + + plugin_name = spec.get("plugin") + if not plugin_name: + raise ValueError("'plugin' not defined") + + plugin_class = plugins.get_plugin_class(plugin_name) + + # validate plugin properties + plugin_class.properties_class.unmarshal(spec) + + # validate common part properties + part_spec = plugins.extract_part_properties(spec, plugin_name=plugin_name) + PartSpec(**part_spec) diff --git a/snapcraft/parts/yaml_utils.py b/snapcraft/parts/yaml_utils.py new file mode 100644 index 0000000000..ebc8dde67f --- /dev/null +++ b/snapcraft/parts/yaml_utils.py @@ -0,0 +1,101 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""YAML utilities for Snapcraft.""" + +from typing import Any, Dict, TextIO + +import yaml +import yaml.error + +from snapcraft import errors, utils + + +def _check_duplicate_keys(node): + mappings = set() + + for key_node, _ in node.value: + try: + if key_node.value in mappings: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + f"found duplicate key {key_node.value!r}", + node.start_mark, + ) + mappings.add(key_node.value) + except TypeError: + # Ignore errors for malformed inputs that will be caught later. + pass + + +def _dict_constructor(loader, node): + _check_duplicate_keys(node) + + # Necessary in order to make yaml merge tags work + loader.flatten_mapping(node) + value = loader.construct_pairs(node) + + try: + return dict(value) + except TypeError as type_error: + raise yaml.constructor.ConstructorError( + "while constructing a mapping", + node.start_mark, + "found unhashable key", + node.start_mark, + ) from type_error + + +class _SafeLoader(yaml.SafeLoader): # pylint: disable=too-many-ancestors + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _dict_constructor + ) + + +def load(filestream: TextIO) -> Dict[str, Any]: + """Load and parse a YAML-formatted file. + + :param filename: The YAML file to load. + + :raises SnapcraftError: if loading didn't succeed. + :raises LegacyFallback: if the project's base is not core22. + """ + try: + data = yaml.safe_load(filestream) + build_base = utils.get_effective_base( + base=data.get("base"), + build_base=data.get("build_base"), + project_type=data.get("type"), + name=data.get("name"), + ) + + if build_base is None: + raise errors.LegacyFallback("no base defined") + if build_base != "core22": + raise errors.LegacyFallback("base is not core22") + except yaml.error.YAMLError as err: + raise errors.SnapcraftError(f"snapcraft.yaml parsing error: {err!s}") from err + + filestream.seek(0) + + try: + return yaml.load(filestream, Loader=_SafeLoader) + except yaml.error.YAMLError as err: + raise errors.SnapcraftError(f"snapcraft.yaml parsing error: {err!s}") from err diff --git a/snapcraft/projects.py b/snapcraft/projects.py new file mode 100644 index 0000000000..1fe926df58 --- /dev/null +++ b/snapcraft/projects.py @@ -0,0 +1,573 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Project file definition and helpers.""" + +import re +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union + +import pydantic +from craft_grammar.models import GrammarSingleEntryDictList, GrammarStr, GrammarStrList +from pydantic import conlist, constr + +from snapcraft import repo, utils +from snapcraft.errors import ProjectValidationError +from snapcraft.parts import validation as parts_validation + + +class ProjectModel(pydantic.BaseModel): + """Base model for snapcraft project classes.""" + + class Config: # pylint: disable=too-few-public-methods + """Pydantic model configuration.""" + + validate_assignment = True + extra = "forbid" + allow_mutation = True # project is updated with adopted metadata + allow_population_by_field_name = True + alias_generator = lambda s: s.replace("_", "-") # noqa: E731 + + +# A workaround for mypy false positives +# see https://github.com/samuelcolvin/pydantic/issues/975#issuecomment-551147305 +# fmt: off +if TYPE_CHECKING: + UniqueStrList = List[str] +else: + UniqueStrList = conlist(str, unique_items=True) +# fmt: on + + +def _validate_command_chain(command_chains: Optional[List[str]]) -> Optional[List[str]]: + """Validate command_chain.""" + if command_chains is not None: + for command_chain in command_chains: + if not re.match(r"^[A-Za-z0-9/._#:$-]*$", command_chain): + raise ValueError( + f"{command_chain!r} is not a valid command chain. Command chain entries must " + "be strings, and can only use ASCII alphanumeric characters and the following " + "special characters: / . _ # : $ -" + ) + return command_chains + + +class Socket(ProjectModel): + """Snapcraft app socket definition.""" + + listen_stream: Union[int, str] + socket_mode: Optional[int] + + @pydantic.validator("listen_stream") + @classmethod + def _validate_list_stream(cls, listen_stream): + if isinstance(listen_stream, int): + if listen_stream < 1 or listen_stream > 65535: + raise ValueError( + f"{listen_stream!r} is not an integer between 1 and 65535 (inclusive)." + ) + elif isinstance(listen_stream, str): + if not re.match(r"^[A-Za-z0-9/._#:$-]*$", listen_stream): + raise ValueError( + f"{listen_stream!r} is not a valid socket path (e.g. /tmp/mysocket.sock)." + ) + + return listen_stream + + +class App(ProjectModel): + """Snapcraft project app definition.""" + + command: str + autostart: Optional[str] + common_id: Optional[str] + bus_name: Optional[str] + desktop: Optional[str] + completer: Optional[str] + stop_command: Optional[str] + post_stop_command: Optional[str] + start_timeout: Optional[str] + stop_timeout: Optional[str] + watchdog_timeout: Optional[str] + reload_command: Optional[str] + restart_delay: Optional[str] + timer: Optional[str] + daemon: Optional[Literal["simple", "forking", "oneshot", "notify", "dbus"]] + after: UniqueStrList = [] + before: UniqueStrList = [] + refresh_mode: Optional[Literal["endure", "restart"]] + stop_mode: Optional[ + Literal[ + "sigterm", + "sigterm-all", + "sighup", + "sighup-all", + "sigusr1", + "sigusr1-all", + "sigusr2", + "sigusr2-all", + ] + ] + restart_condition: Optional[ + Literal[ + "on-success", + "on-failure", + "on-abnormal", + "on-abort", + "on-watchdog", + "always", + "never", + ] + ] + install_mode: Optional[Literal["enable", "disable"]] + slots: Optional[UniqueStrList] + plugs: Optional[UniqueStrList] + aliases: Optional[UniqueStrList] + environment: Optional[Dict[str, str]] + adapter: Optional[Literal["none", "full"]] + command_chain: List[str] = [] + sockets: Optional[Dict[str, Socket]] + # TODO: implement passthrough (CRAFT-854) + + @pydantic.validator("autostart") + @classmethod + def _validate_autostart_name(cls, name): + if not re.match(r"^[A-Za-z0-9. _#:$-]+\.desktop$", name): + raise ValueError( + f"{name!r} is not a valid desktop file name (e.g. myapp.desktop)" + ) + + return name + + @pydantic.validator("bus_name") + @classmethod + def _validate_bus_name(cls, name): + if not re.match(r"^[A-Za-z0-9/. _#:$-]*$", name): + raise ValueError(f"{name!r} is not a valid bus name") + + return name + + @pydantic.validator( + "start_timeout", "stop_timeout", "watchdog_timeout", "restart_delay" + ) + @classmethod + def _validate_time(cls, timeval): + if not re.match(r"^[0-9]+(ns|us|ms|s|m)*$", timeval): + raise ValueError(f"{timeval!r} is not a valid time value") + + return timeval + + @pydantic.validator("command_chain") + @classmethod + def _validate_command_chain(cls, command_chains): + return _validate_command_chain(command_chains) + + @pydantic.validator("aliases") + @classmethod + def _validate_aliases(cls, aliases): + for alias in aliases: + if not re.match(r"^[a-zA-Z0-9][-_.a-zA-Z0-9]*$", alias): + raise ValueError( + f"{alias!r} is not a valid alias. Aliases must be strings, begin with an ASCII " + "alphanumeric character, and can only use ASCII alphanumeric characters and " + "the following special characters: . _ -" + ) + + return aliases + + +class Hook(ProjectModel): + """Snapcraft project hook definition.""" + + command_chain: Optional[List[str]] + environment: Optional[Dict[str, str]] + plugs: Optional[UniqueStrList] + passthrough: Optional[Dict[str, Any]] + + @pydantic.validator("command_chain") + @classmethod + def _validate_command_chain(cls, command_chains): + return _validate_command_chain(command_chains) + + @pydantic.validator("plugs") + @classmethod + def _validate_plugs(cls, plugs): + if not plugs: + raise ValueError("'plugs' field cannot be empty.") + return plugs + + +class Architecture(ProjectModel): + """Snapcraft project architecture definition.""" + + build_on: Union[str, UniqueStrList] + build_to: Optional[Union[str, UniqueStrList]] + + +class ContentPlug(ProjectModel): + """Snapcraft project content plug definition.""" + + content: Optional[str] + interface: str + target: str + default_provider: Optional[str] + + +MANDATORY_ADOPTABLE_FIELDS = ("version", "summary", "description") + + +class Project(ProjectModel): + """Snapcraft project definition. + + See https://snapcraft.io/docs/snapcraft-yaml-reference + + XXX: Not implemented in this version + - system-usernames + """ + + name: constr(max_length=40) # type: ignore + title: Optional[constr(max_length=40)] # type: ignore + base: Optional[str] + build_base: Optional[str] + compression: Literal["lzo", "xz"] = "xz" + version: Optional[constr(max_length=32, strict=True)] # type: ignore + contact: Optional[Union[str, UniqueStrList]] + donation: Optional[Union[str, UniqueStrList]] + issues: Optional[Union[str, UniqueStrList]] + source_code: Optional[str] + website: Optional[str] + summary: Optional[constr(max_length=78)] # type: ignore + description: Optional[str] + type: Optional[Literal["app", "base", "gadget", "kernel", "snapd"]] + icon: Optional[str] + confinement: Literal["classic", "devmode", "strict"] + layout: Optional[ + Dict[str, Dict[Literal["symlink", "bind", "bind-file", "type"], str]] + ] + license: Optional[str] + grade: Optional[Literal["stable", "devel"]] + architectures: List[Architecture] = [] + assumes: UniqueStrList = [] + package_repositories: List[Dict[str, Any]] = [] # handled by repo + hooks: Optional[Dict[str, Hook]] + passthrough: Optional[Dict[str, Any]] + apps: Optional[Dict[str, App]] + plugs: Optional[Dict[str, Union[ContentPlug, Any]]] + slots: Optional[Dict[str, Dict[str, str]]] # TODO: add slot name validation + parts: Dict[str, Any] # parts are handled by craft-parts + epoch: Optional[str] + adopt_info: Optional[str] + environment: Optional[Dict[str, str]] + + @pydantic.validator("plugs") + @classmethod + def _validate_plugs(cls, plugs): + if plugs is not None: + for plug_name, plug in plugs.items(): + if ( + isinstance(plug, dict) + and plug.get("interface") == "content" + and not plug.get("target") + ): + raise ValueError( + f"ContentPlug '{plug_name}' must have a 'target' parameter." + ) + if isinstance(plug, list): + raise ValueError(f"Plug '{plug_name}' cannot be a list.") + + return plugs + + @pydantic.root_validator(pre=True) + @classmethod + def _validate_adoptable_fields(cls, values): + for field in MANDATORY_ADOPTABLE_FIELDS: + if field not in values and "adopt-info" not in values: + raise ValueError(f"Snap {field} is required if not using adopt-info") + return values + + @pydantic.root_validator(pre=True) + @classmethod + def _validate_mandatory_base(cls, values): + snap_type = values.get("type") + base = values.get("base") + if (base is not None) ^ (snap_type not in ["base", "kernel", "snapd"]): + raise ValueError( + "Snap base must be declared when type is not base, kernel or snapd" + ) + return values + + @pydantic.validator("name") + @classmethod + def _validate_name(cls, name): + if not re.match(r"^[a-z0-9-]*[a-z][a-z0-9-]*$", name): + raise ValueError( + "Snap names can only use ASCII lowercase letters, numbers, and hyphens, " + "and must have at least one letter" + ) + + if name.startswith("-"): + raise ValueError("Snap names cannot start with a hyphen") + + if name.endswith("-"): + raise ValueError("Snap names cannot end with a hyphen") + + if "--" in name: + raise ValueError("Snap names cannot have two hyphens in a row") + + return name + + @pydantic.validator("version") + @classmethod + def _validate_version(cls, version, values): + if not version and "adopt_info" not in values: + raise ValueError("Version must be declared if not adopting metadata") + + if version and not re.match( + r"^[a-zA-Z0-9](?:[a-zA-Z0-9:.+~-]*[a-zA-Z0-9+~])?$", version + ): + raise ValueError( + "Snap versions consist of upper- and lower-case alphanumeric characters, " + "as well as periods, colons, plus signs, tildes, and hyphens. They cannot " + "begin with a period, colon, plus sign, tilde, or hyphen. They cannot end " + "with a period, colon, or hyphen" + ) + + return version + + @pydantic.validator("grade", "summary", "description") + @classmethod + def _validate_adoptable_field(cls, field_value, values, field): + if not field_value and "adopt_info" not in values: + raise ValueError( + f"{field.name.capitalize()} must be declared if not adopting metadata" + ) + return field_value + + @pydantic.validator("build_base", always=True) + @classmethod + def _validate_build_base(cls, build_base, values): + """Build-base defaults to the base value if not specified.""" + if not build_base: + build_base = values.get("base") + return build_base + + @pydantic.validator("package_repositories", each_item=True) + @classmethod + def _validate_package_repositories(cls, item): + """Ensure package-repositories format is correct.""" + repo.validate_repository(item) + return item + + @pydantic.validator("parts", each_item=True) + @classmethod + def _validate_parts(cls, item): + """Verify each part (craft-parts will re-validate this).""" + parts_validation.validate_part(item) + return item + + @pydantic.validator("epoch") + @classmethod + def _validate_epoch(cls, epoch): + """Verify epoch format.""" + if epoch is not None and not re.match(r"^(?:0|[1-9][0-9]*[*]?)$", epoch): + raise ValueError( + "Epoch is a positive integer followed by an optional asterisk" + ) + + return epoch + + @classmethod + def unmarshal(cls, data: Dict[str, Any]) -> "Project": + """Create and populate a new ``Project`` object from dictionary data. + + The unmarshal method validates entries in the input dictionary, populating + the corresponding fields in the data object. + + :param data: The dictionary data to unmarshal. + + :return: The newly created object. + + :raise TypeError: If data is not a dictionary. + """ + if not isinstance(data, dict): + raise TypeError("Project data is not a dictionary") + + try: + project = Project(**data) + except pydantic.ValidationError as err: + raise ProjectValidationError(_format_pydantic_errors(err.errors())) from err + + return project + + def _get_content_plugs(self) -> List[ContentPlug]: + """Get list of content plugs.""" + if self.plugs is not None: + return [ + plug for plug in self.plugs.values() if isinstance(plug, ContentPlug) + ] + return [] + + def get_content_snaps(self) -> Optional[List[str]]: + """Get list of snaps from ContentPlug `default-provider` fields.""" + content_snaps = [ + x.default_provider + for x in self._get_content_plugs() + if x.default_provider is not None + ] + + return content_snaps if content_snaps else None + + def get_effective_base(self) -> str: + """Return the base to use to create the snap.""" + base = utils.get_effective_base( + base=self.base, + build_base=self.build_base, + project_type=self.type, + name=self.name, + ) + + # will not happen after schema validation + if base is None: + raise RuntimeError("cannot determine build base") + + return base + + +class _GrammarAwareModel(pydantic.BaseModel): + class Config: + """Default configuration for grammar-aware models.""" + + validate_assignment = True + extra = "allow" # this is required to verify only grammar-aware parts + alias_generator = lambda s: s.replace("_", "-") # noqa: E731 + allow_population_by_field_name = True + + +class _GrammarAwarePart(_GrammarAwareModel): + source: Optional[GrammarStr] + build_environment: Optional[GrammarSingleEntryDictList] + build_packages: Optional[GrammarStrList] + stage_packages: Optional[GrammarStrList] + build_snaps: Optional[GrammarStrList] + stage_snaps: Optional[GrammarStrList] + parse_info: Optional[List[str]] + + +class GrammarAwareProject(_GrammarAwareModel): + """Project definition containing grammar-aware components.""" + + parts: Dict[str, _GrammarAwarePart] + + @classmethod + def validate_grammar(cls, data: Dict[str, Any]) -> None: + """Ensure grammar-enabled entries are syntactically valid.""" + try: + cls(**data) + except pydantic.ValidationError as err: + raise ProjectValidationError(_format_pydantic_errors(err.errors())) from err + + +def _format_pydantic_errors(errors, *, file_name: str = "snapcraft.yaml"): + """Format errors. + + Example 1: Single error. + + Bad snapcraft.yaml content: + - field: + reason: + + Example 2: Multiple errors. + + Bad snapcraft.yaml content: + - field: + reason: + - field: + reason: + """ + combined = [f"Bad {file_name} content:"] + for error in errors: + formatted_loc = _format_pydantic_error_location(error["loc"]) + formatted_msg = _format_pydantic_error_message(error["msg"]) + + if formatted_msg == "field required": + field_name, location = _printable_field_location_split(formatted_loc) + combined.append( + f"- field {field_name} required in {location} configuration" + ) + elif formatted_msg == "extra fields not permitted": + field_name, location = _printable_field_location_split(formatted_loc) + combined.append( + f"- extra field {field_name} not permitted in {location} configuration" + ) + elif formatted_msg == "the list has duplicated items": + field_name, location = _printable_field_location_split(formatted_loc) + combined.append( + f" - duplicate entries in {field_name} not permitted in {location} configuration" + ) + elif formatted_loc == "__root__": + combined.append(f"- {formatted_msg}") + else: + combined.append(f"- {formatted_msg} (in field {formatted_loc!r})") + + return "\n".join(combined) + + +def _format_pydantic_error_location(loc): + """Format location.""" + loc_parts = [] + for loc_part in loc: + if isinstance(loc_part, str): + loc_parts.append(loc_part) + elif isinstance(loc_part, int): + # Integer indicates an index. Go + # back and fix up previous part. + previous_part = loc_parts.pop() + previous_part += f"[{loc_part}]" + loc_parts.append(previous_part) + else: + raise RuntimeError(f"unhandled loc: {loc_part}") + + loc = ".".join(loc_parts) + + # Filter out internal __root__ detail. + loc = loc.replace(".__root__", "") + return loc + + +def _format_pydantic_error_message(msg): + """Format pydantic's error message field.""" + # Replace shorthand "str" with "string". + msg = msg.replace("str type expected", "string type expected") + return msg + + +def _printable_field_location_split(location: str) -> Tuple[str, str]: + """Return split field location. + + If top-level, location is returned as unquoted "top-level". + If not top-level, location is returned as quoted location, e.g. + + (1) field1[idx].foo => 'foo', 'field1[idx]' + (2) field2 => 'field2', top-level + + :returns: Tuple of , as printable representations. + """ + loc_split = location.split(".") + field_name = repr(loc_split.pop()) + + if loc_split: + return field_name, repr(".".join(loc_split)) + + return field_name, "top-level" diff --git a/snapcraft/providers/__init__.py b/snapcraft/providers/__init__.py new file mode 100644 index 0000000000..1172c31ac1 --- /dev/null +++ b/snapcraft/providers/__init__.py @@ -0,0 +1,25 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +"""Build provider support.""" + +from ._buildd import SnapcraftBuilddBaseConfiguration # noqa: F401 +from ._get_provider import get_platform_default_provider, get_provider # noqa: F401 +from ._logs import capture_logs_from_instance # noqa: F401 +from ._lxd import LXDProvider # noqa: F401 +from ._multipass import MultipassProvider # noqa: F401 +from ._provider import Provider # noqa: F401 +from ._provider import ProviderError # noqa: F401 diff --git a/snapcraft/providers/_buildd.py b/snapcraft/providers/_buildd.py new file mode 100644 index 0000000000..848498025f --- /dev/null +++ b/snapcraft/providers/_buildd.py @@ -0,0 +1,143 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Buildd-related helpers for Snapcraft.""" + +import sys +from typing import Optional + +from craft_providers import Executor, bases +from craft_providers.actions import snap_installer +from overrides import overrides + +from snapcraft import utils + +# TODO fix this overengineered configuration +BASE_TO_BUILDD_IMAGE_ALIAS = { + "core22": bases.BuilddBaseAlias.JAMMY, +} + + +class SnapcraftBuilddBaseConfiguration(bases.BuilddBase): + """Base configuration for Snapcraft. + + :cvar compatibility_tag: Tag/Version for variant of build configuration and + setup. Any change to this version would indicate that prior [versioned] + instances are incompatible and must be cleaned. As such, any new value + should be unique to old values (e.g. incrementing). Snapcraft extends + the buildd tag to include its own version indicator (.0) and namespace + ("snapcraft"). + """ + + compatibility_tag: str = f"snapcraft-{bases.BuilddBase.compatibility_tag}.0" + + @staticmethod + def _setup_snapcraft(*, executor: Executor) -> None: + """Install Snapcraft in target environment. + + On Linux, the default behavior is to inject the host snap into the target + environment. + + On other platforms, the Snapcraft snap is installed from the Snap Store. + + When installing the snap from the Store, we check if the user specifies a + channel, using SNAPCRAFT_INSTALL_SNAP_CHANNEL=. If unspecified, + we use the "stable" channel on the default track. + + On Linux, the user may specify this environment variable to force Snapcraft + to install the snap from the Store rather than inject the host snap. + + :raises BaseConfigurationError: on error. + """ + # Requirement for apt gpg + executor.execute_run( + ["apt-get", "install", "-y", "dirmngr"], + capture_output=True, + check=True, + ) + + snap_channel = utils.get_managed_environment_snap_channel() + if snap_channel is None and sys.platform != "linux": + snap_channel = "stable" + + # Snaps that are already installed won't be reinstalled. + # See https://github.com/canonical/craft-providers/issues/91 + + if snap_channel: + try: + snap_installer.install_from_store( + executor=executor, + snap_name="snapcraft", + channel=snap_channel, + classic=True, + ) + except snap_installer.SnapInstallationError as error: + raise bases.BaseConfigurationError( + "Failed to install snapcraft snap from store channel " + f"{snap_channel!r} into target environment." + ) from error + else: + try: + snap_installer.inject_from_host( + executor=executor, snap_name="snapcraft", classic=True + ) + except snap_installer.SnapInstallationError as error: + raise bases.BaseConfigurationError( + "Failed to inject host snapcraft snap into target environment." + ) from error + + @overrides + def setup( + self, + *, + executor: Executor, + retry_wait: float = 0.25, + timeout: Optional[float] = None, + ) -> None: + """Prepare base instance for use by the application. + + :param executor: Executor for target container. + :param retry_wait: Duration to sleep() between status checks (if required). + :param timeout: Timeout in seconds. + + :raises BaseCompatibilityError: if instance is incompatible. + :raises BaseConfigurationError: on other unexpected error. + """ + super().setup(executor=executor, retry_wait=retry_wait, timeout=timeout) + self._setup_snapcraft(executor=executor) + + @overrides + def warmup( + self, + *, + executor: Executor, + retry_wait: float = 0.25, + timeout: Optional[float] = None, + ) -> None: + """Prepare a previously created and setup instance for use by the application. + + In addition to the guarantees provided by buildd: + - snapcraft installed + + :param executor: Executor for target container. + :param retry_wait: Duration to sleep() between status checks (if required). + :param timeout: Timeout in seconds. + + :raises BaseCompatibilityError: if instance is incompatible. + :raises BaseConfigurationError: on other unexpected error. + """ + super().warmup(executor=executor, retry_wait=retry_wait, timeout=timeout) + self._setup_snapcraft(executor=executor) diff --git a/snapcraft/providers/_get_provider.py b/snapcraft/providers/_get_provider.py new file mode 100644 index 0000000000..1f8e8a4338 --- /dev/null +++ b/snapcraft/providers/_get_provider.py @@ -0,0 +1,67 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Build environment provider support for snapcraft.""" + +import os +import sys +from typing import Optional + +from ._lxd import LXDProvider +from ._multipass import MultipassProvider +from ._provider import Provider + + +def get_provider(provider: Optional[str] = None) -> Provider: + """Get the configured or appropriate provider for the host OS. + + If platform is not Linux, use Multipass. + + If platform is Linux: + (1) use provider specified in the function argument, + (2) use provider specified with snap configuration if running + as snap, + (3) get the provider from the environment if valid, + (4) default to platform default (LXD on Linux). + + :return: Provider instance. + """ + env_provider = os.getenv("SNAPCRAFT_BUILD_ENVIRONMENT") + env_provider_is_valid = env_provider in ("lxd", "multipass") + + if provider is None and env_provider_is_valid: + provider = env_provider + elif provider is None: + provider = get_platform_default_provider() + + if provider == "lxd": + return LXDProvider() + + if provider == "multipass": + return MultipassProvider() + + raise RuntimeError(f"Unsupported provider specified: {provider!r}.") + + +def get_platform_default_provider() -> str: + """Obtain the default provider for the host platform. + + :return: Default provider name. + """ + if sys.platform == "linux": + return "lxd" + + return "multipass" diff --git a/snapcraft/providers/_logs.py b/snapcraft/providers/_logs.py new file mode 100644 index 0000000000..56161fdcbe --- /dev/null +++ b/snapcraft/providers/_logs.py @@ -0,0 +1,51 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Build environment provider support for snapcraft.""" + +import pathlib +import tempfile + +from craft_cli import emit +from craft_providers import Executor + +from snapcraft.utils import get_managed_environment_log_path + + +def capture_logs_from_instance(instance: Executor) -> None: + """Retrieve logs from instance. + + :param instance: Instance to retrieve logs from. + + :returns: String of logs. + """ + # Get a temporary file path. + with tempfile.NamedTemporaryFile(delete=False, prefix="snapcraft-") as tmp_file: + local_log_path = pathlib.Path(tmp_file.name) + + instance_log_path = get_managed_environment_log_path() + + try: + instance.pull_file(source=instance_log_path, destination=local_log_path) + except FileNotFoundError: + emit.trace("No logs found in instance.") + return + + emit.trace("Logs captured from managed instance:") + with local_log_path.open("rt", encoding="utf8") as logfile: + for line in logfile: + emit.trace(":: " + line.rstrip()) + local_log_path.unlink() diff --git a/snapcraft/providers/_lxd.py b/snapcraft/providers/_lxd.py new file mode 100644 index 0000000000..cc2a7797fe --- /dev/null +++ b/snapcraft/providers/_lxd.py @@ -0,0 +1,201 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""LXD build environment provider support for Snapcraft.""" + +import contextlib +import logging +import os +import pathlib +from typing import Generator, List + +from craft_providers import Executor, bases, lxd + +from snapcraft.utils import confirm_with_user, get_managed_environment_project_path + +from ._buildd import BASE_TO_BUILDD_IMAGE_ALIAS, SnapcraftBuilddBaseConfiguration +from ._provider import Provider, ProviderError + +logger = logging.getLogger(__name__) + + +class LXDProvider(Provider): + """LXD build environment provider. + + :param lxc: Optional lxc client to use. + :param lxd_project: LXD project to use (default is snapcraft). + :param lxd_remote: LXD remote to use (default is local). + """ + + def __init__( + self, + *, + lxc: lxd.LXC = lxd.LXC(), + lxd_project: str = "snapcraft", + lxd_remote: str = "local", + ) -> None: + self.lxc = lxc + self.lxd_project = lxd_project + self.lxd_remote = lxd_remote + + def clean_project_environments( + self, *, project_name: str, project_path: pathlib.Path + ) -> List[str]: + """Clean up any build environments created for project. + + :param project_name: Name of project. + + :returns: List of containers deleted. + """ + deleted: List[str] = [] + + # Nothing to do if provider is not installed. + if not self.is_provider_available(): + return deleted + + instance_name = self.get_instance_name( + project_name=project_name, + project_path=project_path, + ) + + try: + names = self.lxc.list_names( + project=self.lxd_project, remote=self.lxd_remote + ) + except lxd.LXDError as error: + raise ProviderError(str(error)) from error + + for name in names: + if name == instance_name: + logger.debug("Deleting container %r.", name) + try: + self.lxc.delete( + instance_name=name, + force=True, + project=self.lxd_project, + remote=self.lxd_remote, + ) + except lxd.LXDError as error: + raise ProviderError(str(error)) from error + deleted.append(name) + else: + logger.debug("Not deleting container %r.", name) + + return deleted + + @classmethod + def ensure_provider_is_available(cls) -> None: + """Ensure provider is available, prompting the user to install it if required. + + :raises ProviderError: if provider is not available. + """ + if not lxd.is_installed(): + if confirm_with_user( + "LXD is required, but not installed. Do you wish to install LXD " + "and configure it with the defaults?", + default=False, + ): + try: + lxd.install() + except lxd.LXDInstallationError as error: + raise ProviderError( + "Failed to install LXD. Visit https://snapcraft.io/lxd for " + "instructions on how to install the LXD snap for your distribution", + ) from error + else: + raise ProviderError( + "LXD is required, but not installed. Visit https://snapcraft.io/lxd " + "for instructions on how to install the LXD snap for your distribution", + ) + + try: + lxd.ensure_lxd_is_ready() + except lxd.LXDError as error: + raise ProviderError(str(error)) from error + + @classmethod + def is_provider_available(cls) -> bool: + """Check if provider is installed and available for use. + + :returns: True if installed. + """ + return lxd.is_installed() + + @contextlib.contextmanager + def launched_environment( + self, + *, + project_name: str, + project_path: pathlib.Path, + base: str, + ) -> Generator[Executor, None, None]: + """Launch environment for specified base. + + :param project_name: Name of project. + :param project_path: Path to project. + :param base: Base to create. + """ + alias = BASE_TO_BUILDD_IMAGE_ALIAS[base] + + instance_name = self.get_instance_name( + project_name=project_name, + project_path=project_path, + ) + alias = BASE_TO_BUILDD_IMAGE_ALIAS[base] + try: + image_remote = lxd.configure_buildd_image_remote() + except lxd.LXDError as error: + raise ProviderError(str(error)) from error + + environment = self.get_command_environment() + + base_configuration = SnapcraftBuilddBaseConfiguration( + alias=alias, + environment=environment, + hostname=instance_name, + ) + + try: + instance = lxd.launch( + name=instance_name, + base_configuration=base_configuration, + image_name=base, + image_remote=image_remote, + auto_clean=True, + auto_create_project=True, + map_user_uid=True, + uid=os.stat(project_path).st_uid, + use_snapshots=True, + project=self.lxd_project, + remote=self.lxd_remote, + ) + except (bases.BaseConfigurationError, lxd.LXDError) as error: + raise ProviderError(str(error)) from error + + # Mount project. + instance.mount( + host_source=project_path, target=get_managed_environment_project_path() + ) + + try: + yield instance + finally: + # Ensure to unmount everything and stop instance upon completion. + try: + instance.unmount_all() + instance.stop() + except lxd.LXDError as error: + raise ProviderError(str(error)) from error diff --git a/snapcraft/providers/_multipass.py b/snapcraft/providers/_multipass.py new file mode 100644 index 0000000000..470f2063da --- /dev/null +++ b/snapcraft/providers/_multipass.py @@ -0,0 +1,185 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Multipass build environment provider for Snapcraft.""" + +import contextlib +import logging +import pathlib +from typing import Generator, List + +from craft_cli import emit +from craft_providers import Executor, bases, multipass +from craft_providers.multipass.errors import MultipassError + +from snapcraft.utils import confirm_with_user, get_managed_environment_project_path + +from ._buildd import BASE_TO_BUILDD_IMAGE_ALIAS, SnapcraftBuilddBaseConfiguration +from ._provider import Provider, ProviderError + +logger = logging.getLogger(__name__) + + +class MultipassProvider(Provider): + """Multipass build environment provider. + + :param multipass: Optional Multipass client to use. + """ + + def __init__( + self, + instance: multipass.Multipass = multipass.Multipass(), + ) -> None: + self.multipass = instance + + def clean_project_environments( + self, *, project_name: str, project_path: pathlib.Path + ) -> List[str]: + """Clean up any build environments created for project. + + :param project_name: Name of the project. + :param project_path: Directory of the project. + + :returns: List of containers deleted. + """ + deleted: List[str] = [] + + # Nothing to do if provider is not installed. + if not self.is_provider_available(): + return deleted + + inode = project_path.stat().st_ino + + try: + names = self.multipass.list() + except multipass.MultipassError as error: + raise ProviderError(str(error)) from error + + for name in names: + if name == f"snapcraft-{project_name}-{inode}": + logger.debug("Deleting Multipass VM %r.", name) + try: + self.multipass.delete( + instance_name=name, + purge=True, + ) + except multipass.MultipassError as error: + raise ProviderError(str(error)) from error + + deleted.append(name) + else: + logger.debug("Not deleting Multipass VM %r.", name) + + return deleted + + @classmethod + def ensure_provider_is_available(cls) -> None: + """Ensure provider is available, prompting the user to install it if required. + + :raises ProviderError: if provider is not available. + """ + if not multipass.is_installed(): + with emit.pause(): + confirmation = confirm_with_user( + "Multipass is required, but not installed. Do you wish to install Multipass " + "and configure it with the defaults?", + default=False, + ) + if confirmation: + try: + multipass.install() + except multipass.MultipassInstallationError as error: + raise ProviderError( + "Failed to install Multipass. Visit https://multipass.run/ for " + "instructions on installing Multipass for your operating system.", + ) from error + else: + raise ProviderError( + "Multipass is required, but not installed. Visit https://multipass.run/ for " + "instructions on installing Multipass for your operating system.", + ) + + try: + multipass.ensure_multipass_is_ready() + except multipass.MultipassError as error: + raise ProviderError(str(error)) from error + + @classmethod + def is_provider_available(cls) -> bool: + """Check if provider is installed and available for use. + + :returns: True if installed. + """ + return multipass.is_installed() + + @contextlib.contextmanager + def launched_environment( + self, + *, + project_name: str, + project_path: pathlib.Path, + base: str, + ) -> Generator[Executor, None, None]: + """Launch environment for specified base. + + :param project_name: Name of the project. + :param project_path: Path to project. + :param base: Base to create. + """ + alias = BASE_TO_BUILDD_IMAGE_ALIAS[base] + + instance_name = self.get_instance_name( + project_name=project_name, + project_path=project_path, + ) + + environment = self.get_command_environment() + base_configuration = SnapcraftBuilddBaseConfiguration( + alias=alias, # type: ignore + environment=environment, + hostname=instance_name, + ) + + try: + instance = multipass.launch( + name=instance_name, + base_configuration=base_configuration, + image_name=f"snapcraft:{base}", + cpus=2, + disk_gb=64, + mem_gb=2, + auto_clean=True, + ) + except (bases.BaseConfigurationError, MultipassError) as error: + raise ProviderError(str(error)) from error + + try: + # Mount project. + instance.mount( + host_source=project_path, target=get_managed_environment_project_path() + ) + except MultipassError as error: + raise ProviderError(str(error)) from error + + try: + yield instance + finally: + # Ensure to unmount everything and stop instance upon completion. + try: + instance.unmount_all() + instance.stop() + except MultipassError as error: + raise ProviderError(str(error)) from error diff --git a/snapcraft/providers/_provider.py b/snapcraft/providers/_provider.py new file mode 100644 index 0000000000..8c056651fb --- /dev/null +++ b/snapcraft/providers/_provider.py @@ -0,0 +1,125 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Build environment provider support for snapcraft.""" + +import contextlib +import os +import pathlib +from abc import ABC, abstractmethod +from typing import Dict, Generator, List, Optional, Tuple, Union + +from craft_providers import Executor, bases + +from snapcraft.errors import SnapcraftError + + +class ProviderError(SnapcraftError): + """Error in provider operation.""" + + +class Provider(ABC): + """Snapcraft's build environment provider.""" + + @abstractmethod + def clean_project_environments( + self, *, project_name: str, project_path: pathlib.Path + ) -> List[str]: + """Clean up any environments created for project. + + :param project_name: Name of project. + + :returns: List of containers deleted. + """ + + @classmethod + @abstractmethod + def ensure_provider_is_available(cls) -> None: + """Ensure provider is available, prompting the user to install it if required. + + :raises ProviderError: if provider is not available. + """ + + @staticmethod + def get_command_environment() -> Dict[str, Optional[str]]: + """Construct the required environment.""" + env = bases.buildd.default_command_environment() + env["SNAPCRAFT_MANAGED_MODE"] = "1" + + # Pass-through host environment that target may need. + for env_key in ["http_proxy", "https_proxy", "no_proxy"]: + if env_key in os.environ: + env[env_key] = os.environ[env_key] + + return env + + @staticmethod + def get_instance_name( + *, + project_name: str, + project_path: pathlib.Path, + ) -> str: + """Formulate the name for an instance using each of the given parameters. + + Incorporate each of the parameters into the name to come up with a + predictable naming schema that avoids name collisions across multiple + projects. + + :param project_name: Name of the project. + :param project_path: Directory of the project. + """ + return "-".join(["snapcraft", project_name, str(project_path.stat().st_ino)]) + + @classmethod + def is_base_available(cls, base: str) -> Tuple[bool, Union[str, None]]: + """Check if provider can provide an environment matching given base. + + :param base: Base to check. + + :returns: Tuple of bool indicating whether it is a match, with optional + reason if not a match. + """ + if base not in ["ubuntu:18.04", "ubuntu:20.04"]: + return ( + False, + f"Base {base!r} is not supported (must be 'ubuntu:18.04' or 'ubuntu:20.04')", + ) + + return True, None + + @classmethod + @abstractmethod + def is_provider_available(cls) -> bool: + """Check if provider is installed and available for use. + + :returns: True if installed. + """ + + @abstractmethod + @contextlib.contextmanager + def launched_environment( + self, + *, + project_name: str, + project_path: pathlib.Path, + base: str, + ) -> Generator[Executor, None, None]: + """Launch environment for specified base. + + :param project_name: Name of the project. + :param project_path: Path to the project. + :param base: Base to create. + """ diff --git a/snapcraft/repo/__init__.py b/snapcraft/repo/__init__.py new file mode 100644 index 0000000000..326519fa5b --- /dev/null +++ b/snapcraft/repo/__init__.py @@ -0,0 +1,25 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Package repository helpers.""" + +from .installer import install +from .projects import validate_repository + +__all__ = [ + "install", + "validate_repository", +] diff --git a/snapcraft/repo/apt_key_manager.py b/snapcraft/repo/apt_key_manager.py new file mode 100644 index 0000000000..89aa3be7a5 --- /dev/null +++ b/snapcraft/repo/apt_key_manager.py @@ -0,0 +1,228 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2015-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""APT key management helpers.""" + +import pathlib +import subprocess +import tempfile +from typing import List, Optional + +import gnupg +from craft_cli import emit + +from . import apt_ppa, errors, package_repository + + +class AptKeyManager: + """Manage APT repository keys.""" + + def __init__( + self, + *, + gpg_keyring: pathlib.Path = pathlib.Path( + "/etc/apt/trusted.gpg.d/snapcraft.gpg" + ), + key_assets: pathlib.Path, + ) -> None: + self._gpg_keyring = gpg_keyring + self._key_assets = key_assets + + def find_asset_with_key_id(self, *, key_id: str) -> Optional[pathlib.Path]: + """Find snap key asset matching key_id. + + The key asset much be named with the last 8 characters of the key + identifier, in upper case. + + :param key_id: Key ID to search for. + + :returns: Path of key asset if match found, otherwise None. + """ + key_file = key_id[-8:].upper() + ".asc" + key_path = self._key_assets / key_file + + if key_path.exists(): + return key_path + + return None + + @classmethod + def get_key_fingerprints(cls, *, key: str) -> List[str]: + """List fingerprints found in specified key. + + Do this by importing the key into a temporary keyring, + then querying the keyring for fingerprints. + + :param key: Key data (string) to parse. + + :returns: List of key fingerprints/IDs. + """ + with tempfile.NamedTemporaryFile(suffix="keyring") as temp_file: + return ( + gnupg.GPG(keyring=temp_file.name).import_keys(key_data=key).fingerprints + ) + + @classmethod + def is_key_installed(cls, *, key_id: str) -> bool: + """Check if specified key_id is installed. + + Check if key is installed by attempting to export the key. + Unfortunately, apt-key does not exit with error and + we have to do our best to parse the output. + + :param key_id: Key ID to check for. + + :returns: True if key is installed. + """ + try: + proc = subprocess.run( + ["apt-key", "export", key_id], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + ) + except subprocess.CalledProcessError as error: + # Export shouldn't exit with failure based on testing, + # but assume the key is not installed and log a warning. + emit.message( + f"Unexpected apt-key failure: {error.output}", intermediate=True + ) + return False + + apt_key_output = proc.stdout.decode() + + if "BEGIN PGP PUBLIC KEY BLOCK" in apt_key_output: + return True + + if "nothing exported" in apt_key_output: + return False + + # The two strings above have worked in testing, but if neither is + # present for whatever reason, assume the key is not installed + # and log a warning. + emit.message(f"Unexpected apt-key output: {apt_key_output}", intermediate=True) + return False + + def install_key(self, *, key: str) -> None: + """Install given key. + + :param key: Key to install. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + cmd = [ + "apt-key", + "--keyring", + str(self._gpg_keyring), + "add", + "-", + ] + + try: + emit.trace(f"Executing: {cmd!r}") + env = {} + env["LANG"] = "C.UTF-8" + subprocess.run( + cmd, + input=key.encode(), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + env=env, + ) + except subprocess.CalledProcessError as error: + raise errors.AptGPGKeyInstallError(error.output.decode(), key=key) + + emit.trace(f"Installed apt repository key:\n{key}") + + def install_key_from_keyserver( + self, *, key_id: str, key_server: str = "keyserver.ubuntu.com" + ) -> None: + """Install key from specified key server. + + :param key_id: Key ID to install. + :param key_server: Key server to query. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + env = {} + env["LANG"] = "C.UTF-8" + + cmd = [ + "apt-key", + "--keyring", + str(self._gpg_keyring), + "adv", + "--keyserver", + key_server, + "--recv-keys", + key_id, + ] + + try: + emit.trace(f"Executing: {cmd!r}") + subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=True, + env=env, + ) + except subprocess.CalledProcessError as error: + raise errors.AptGPGKeyInstallError( + error.output.decode(), key_id=key_id, key_server=key_server + ) + + def install_package_repository_key( + self, *, package_repo: package_repository.PackageRepository + ) -> bool: + """Install required key for specified package repository. + + For both PPA and other Apt package repositories: + 1) If key is already installed, return False. + 2) Install key from local asset, if available. + 3) Install key from key server, if available. An unspecified + keyserver will default to using keyserver.ubuntu.com. + + :param package_repo: Apt PackageRepository configuration. + + :returns: True if key configuration was changed. False if + key already installed. + + :raises: AptGPGKeyInstallError if unable to install key. + """ + key_server: Optional[str] = None + if isinstance(package_repo, package_repository.PackageRepositoryAptPPA): + key_id = apt_ppa.get_launchpad_ppa_key_id(ppa=package_repo.ppa) + elif isinstance(package_repo, package_repository.PackageRepositoryApt): + key_id = package_repo.key_id + key_server = package_repo.key_server + else: + raise RuntimeError(f"unhandled package repo type: {package_repo!r}") + + # Already installed, nothing to do. + if self.is_key_installed(key_id=key_id): + return False + + key_path = self.find_asset_with_key_id(key_id=key_id) + if key_path is not None: + self.install_key(key=key_path.read_text()) + else: + if key_server is None: + key_server = "keyserver.ubuntu.com" + self.install_key_from_keyserver(key_id=key_id, key_server=key_server) + + return True diff --git a/snapcraft/repo/apt_ppa.py b/snapcraft/repo/apt_ppa.py new file mode 100644 index 0000000000..d022a04bad --- /dev/null +++ b/snapcraft/repo/apt_ppa.py @@ -0,0 +1,50 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2020-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Personal Package Archive helpers.""" + +from typing import Tuple + +import lazr.restfulclient.errors +from craft_cli import emit +from launchpadlib.launchpad import Launchpad + +from . import errors + + +def split_ppa_parts(*, ppa: str) -> Tuple[str, str]: + """Obtain user and repository components from a PPA line.""" + ppa_split = ppa.split("/") + if len(ppa_split) != 2: + raise errors.AptPPAInstallError(ppa, "invalid PPA format") + return ppa_split[0], ppa_split[1] + + +def get_launchpad_ppa_key_id(*, ppa: str) -> str: + """Query Launchpad for PPA's key ID.""" + owner, name = split_ppa_parts(ppa=ppa) + launchpad = Launchpad.login_anonymously("snapcraft", "production") + launchpad_url = f"~{owner}/+archive/{name}" + + emit.trace(f"Loading launchpad url: {launchpad_url}") + try: + key_id = launchpad.load(launchpad_url).signing_key_fingerprint + except lazr.restfulclient.errors.NotFound as error: + raise errors.AptPPAInstallError(ppa, "not found on launchpad") from error + + emit.trace(f"Retrieved launchpad PPA key ID: {key_id}") + + return key_id diff --git a/snapcraft/repo/apt_sources_manager.py b/snapcraft/repo/apt_sources_manager.py new file mode 100644 index 0000000000..0139a62f6b --- /dev/null +++ b/snapcraft/repo/apt_sources_manager.py @@ -0,0 +1,211 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2015-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +"""Manage the host's apt source repository configuration.""" + +import io +import pathlib +import re +from typing import List, Optional + +from craft_cli import emit + +from snapcraft import os_release, utils + +from . import apt_ppa, package_repository + + +def _construct_deb822_source( + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + suites: List[str], + url: str, +) -> str: + """Construct deb-822 formatted sources.list config string.""" + with io.StringIO() as deb822: + if formats: + type_text = " ".join(formats) + else: + type_text = "deb" + + print(f"Types: {type_text}", file=deb822) + + print(f"URIs: {url}", file=deb822) + + suites_text = " ".join(suites) + print(f"Suites: {suites_text}", file=deb822) + + if components: + components_text = " ".join(components) + print(f"Components: {components_text}", file=deb822) + + if architectures: + arch_text = " ".join(architectures) + else: + arch_text = utils.get_host_architecture() + + print(f"Architectures: {arch_text}", file=deb822) + + return deb822.getvalue() + + +class AptSourcesManager: + """Manage apt source configuration in /etc/apt/sources.list.d. + + :param sources_list_d: Path to sources.list.d directory. + """ + + # pylint: disable=too-few-public-methods + def __init__( + self, + *, + sources_list_d: pathlib.Path = pathlib.Path("/etc/apt/sources.list.d"), + ) -> None: + self._sources_list_d = sources_list_d + + def _install_sources( + self, + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + name: str, + suites: List[str], + url: str, + ) -> bool: + """Install sources list configuration. + + Write config to: + /etc/apt/sources.list.d/snapcraft-.sources + + :returns: True if configuration was changed. + """ + config = _construct_deb822_source( + architectures=architectures, + components=components, + formats=formats, + suites=suites, + url=url, + ) + + if name not in ["default", "default-security"]: + name = "snapcraft-" + name + + config_path = self._sources_list_d / f"{name}.sources" + if config_path.exists() and config_path.read_text() == config: + # Already installed and matches, nothing to do. + emit.trace(f"Ignoring unchanged sources: {config_path!s}") + return False + + config_path.write_text(config) + emit.trace(f"Installed sources: {config_path!s}") + return True + + def _install_sources_apt( + self, *, package_repo: package_repository.PackageRepositoryApt + ) -> bool: + """Install repository configuration. + + 1) First check to see if package repo is implied path, + or "bare repository" config. This is indicated when no + path, components, or suites are indicated. + 2) If path is specified, convert path to a suite entry, + ending with "/". + + Relatedly, this assumes all of the error-checking has been + done already on the package_repository object in a proper + fashion, but do some sanity checks here anyways. + + :returns: True if source configuration was changed. + """ + if ( + not package_repo.path + and not package_repo.components + and not package_repo.suites + ): + suites = ["/"] + elif package_repo.path: + # Suites denoting exact path must end with '/'. + path = package_repo.path + if not path.endswith("/"): + path += "/" + suites = [path] + elif package_repo.suites: + suites = package_repo.suites + if not package_repo.components: + raise RuntimeError("no components with suite") + else: + raise RuntimeError("no suites or path") + + if package_repo.name: + name = package_repo.name + else: + name = re.sub(r"\W+", "_", package_repo.url) + + return self._install_sources( + architectures=package_repo.architectures, + components=package_repo.components, + formats=package_repo.formats, + name=name, + suites=suites, + url=package_repo.url, + ) + + def _install_sources_ppa( + self, *, package_repo: package_repository.PackageRepositoryAptPPA + ) -> bool: + """Install PPA formatted repository. + + Create a sources list config by: + - Looking up the codename of the host OS and using it as the "suites" + entry. + - Formulate deb URL to point to PPA. + - Enable only "deb" formats. + + :returns: True if source configuration was changed. + """ + owner, name = apt_ppa.split_ppa_parts(ppa=package_repo.ppa) + codename = os_release.OsRelease().version_codename() + + return self._install_sources( + components=["main"], + formats=["deb"], + name=f"ppa-{owner}_{name}", + suites=[codename], + url=f"http://ppa.launchpad.net/{owner}/{name}/ubuntu", + ) + + def install_package_repository_sources( + self, + *, + package_repo: package_repository.PackageRepository, + ) -> bool: + """Install configured package repositories. + + :param package_repo: Repository to install the source configuration for. + + :returns: True if source configuration was changed. + """ + emit.trace(f"Processing repo: {package_repo!r}") + if isinstance(package_repo, package_repository.PackageRepositoryAptPPA): + return self._install_sources_ppa(package_repo=package_repo) + + if isinstance(package_repo, package_repository.PackageRepositoryApt): + return self._install_sources_apt(package_repo=package_repo) + + raise RuntimeError(f"unhandled package repository: {package_repository!r}") diff --git a/snapcraft/repo/errors.py b/snapcraft/repo/errors.py new file mode 100644 index 0000000000..d452b5c971 --- /dev/null +++ b/snapcraft/repo/errors.py @@ -0,0 +1,105 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Package repository error definitions.""" + +from typing import Optional + +from snapcraft.errors import SnapcraftError + + +class PackageRepositoryError(SnapcraftError): + """Package repository error base.""" + + +class PackageRepositoryValidationError(PackageRepositoryError): + """Package repository is invalid.""" + + def __init__( + self, + url: str, + brief: str, + details: Optional[str] = None, + resolution: Optional[str] = None, + ): + super().__init__( + f"Invalid package repository for {url!r}: {brief}", + details=details, + resolution=resolution, + ) + + +class AptPPAInstallError(PackageRepositoryError): + """Installation of a PPA repository failed.""" + + def __init__(self, ppa: str, reason: str): + super().__init__( + f"Failed to install PPA {ppa!r}: {reason}", + resolution="Verify PPA is correct and try again", + ) + + +class AptGPGKeyInstallError(PackageRepositoryError): + """Installation of GPG key failed.""" + + def __init__( + self, + output: str, + *, + key: Optional[str] = None, + key_id: Optional[str] = None, + key_server: Optional[str] = None, + ): + """Convert apt-key's output into a more user-friendly message.""" + message = output.replace( + "Warning: apt-key output should not be parsed (stdout is not a terminal)", + "", + ).strip() + + # Improve error messages that we can. + if ( + "gpg: keyserver receive failed: No data" in message + and key_id + and key_server + ): + message = f"GPG key {key_id!r} not found on key server {key_server!r}" + elif ( + "gpg: keyserver receive failed: Server indicated a failure" in message + and key_server + ): + message = f"unable to establish connection to key server {key_server!r}" + elif ( + "gpg: keyserver receive failed: Connection timed out" in message + and key_server + ): + message = ( + f"unable to establish connection to key server {key_server!r} " + f"(connection timed out)" + ) + + details = "" + if key: + details += f"GPG key:\n{key}\n" + if key_id: + details += f"GPG key ID: {key_id}\n" + if key_server: + details += f"GPG key server: {key_server}" + + super().__init__( + f"Failed to install GPG key: {message}", + details=details, + resolution="Verify any configured GPG keys", + ) diff --git a/snapcraft/repo/installer.py b/snapcraft/repo/installer.py new file mode 100644 index 0000000000..0bc173318f --- /dev/null +++ b/snapcraft/repo/installer.py @@ -0,0 +1,93 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2019-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Package repository installer.""" + +import pathlib +from typing import Any, Dict, List + +from . import errors +from .apt_key_manager import AptKeyManager +from .apt_sources_manager import AptSourcesManager +from .package_repository import ( + PackageRepository, + PackageRepositoryApt, + PackageRepositoryAptPPA, +) + + +def install( + project_repositories: List[Dict[str, Any]], *, key_assets: pathlib.Path +) -> bool: + """Add package repositories to the host system. + + :param package_repositories: A list of package repositories to install. + :param key_assets: The directory containing repository keys. + + :return: Whether a package list refresh is required. + """ + key_manager = AptKeyManager(key_assets=key_assets) + sources_manager = AptSourcesManager() + + package_repositories = _unmarshal_repositories(project_repositories) + + refresh_required = False + for package_repo in package_repositories: + refresh_required |= key_manager.install_package_repository_key( + package_repo=package_repo + ) + refresh_required |= sources_manager.install_package_repository_sources( + package_repo=package_repo + ) + + _verify_all_key_assets_installed(key_assets=key_assets, key_manager=key_manager) + + return refresh_required + + +def _verify_all_key_assets_installed( + *, + key_assets: pathlib.Path, + key_manager: AptKeyManager, +) -> None: + """Verify all configured key assets are utilized, error if not.""" + for key_asset in key_assets.glob("*"): + key = key_asset.read_text() + for key_id in key_manager.get_key_fingerprints(key=key): + if not key_manager.is_key_installed(key_id=key_id): + raise errors.PackageRepositoryError( + "Found unused key asset {key_asset!r}.", + details="All configured key assets must be utilized.", + resolution="Verify key usage and remove all unused keys.", + ) + + +def _unmarshal_repositories( + project_repositories: List[Dict[str, Any]] +) -> List[PackageRepository]: + """Create package repositories objects from project data.""" + repositories = [] + for data in project_repositories: + pkg_repo: PackageRepository + + if "ppa" in data: + pkg_repo = PackageRepositoryAptPPA.unmarshal(data) + else: + pkg_repo = PackageRepositoryApt.unmarshal(data) + + repositories.append(pkg_repo) + + return repositories diff --git a/snapcraft/repo/package_repository.py b/snapcraft/repo/package_repository.py new file mode 100644 index 0000000000..0cfb0ba850 --- /dev/null +++ b/snapcraft/repo/package_repository.py @@ -0,0 +1,513 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2019-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Package repository definitions.""" + +import abc +import re +from copy import deepcopy +from typing import Any, Dict, List, Optional + +from overrides import overrides + +from . import errors + + +class PackageRepository(abc.ABC): + """The base class for package repositories.""" + + @abc.abstractmethod + def marshal(self) -> Dict[str, Any]: + """Return the package repository data as a dictionary.""" + + @classmethod + def unmarshal(cls, data: Dict[str, str]) -> "PackageRepository": + """Create a package repository object from the given data.""" + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief="invalid object.", + details="Package repository must be a valid dictionary object.", + resolution=( + "Verify repository configuration and ensure that the " + "correct syntax is used." + ), + ) + + if "ppa" in data: + return PackageRepositoryAptPPA.unmarshal(data) + + return PackageRepositoryApt.unmarshal(data) + + @classmethod + def unmarshal_package_repositories(cls, data: Any) -> List["PackageRepository"]: + """Create multiple package repositories from the given data.""" + repositories = [] + + if data is not None: + if not isinstance(data, list): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief="invalid list object.", + details="Package repositories must be a list of objects.", + resolution=( + "Verify 'package-repositories' configuration and ensure " + "that the correct syntax is used." + ), + ) + + for repository in data: + package_repo = cls.unmarshal(repository) + repositories.append(package_repo) + + return repositories + + +class PackageRepositoryAptPPA(PackageRepository): + """A PPA package repository.""" + + def __init__(self, *, ppa: str) -> None: + self.type = "apt" + self.ppa = ppa + + self.validate() + + @overrides + def marshal(self) -> Dict[str, Any]: + """Return the package repository data as a dictionary.""" + data: Dict[str, Any] = {"type": "apt"} + data["ppa"] = self.ppa + return data + + def validate(self) -> None: + """Ensure the current repository data is valid.""" + if not self.ppa: + raise errors.PackageRepositoryValidationError( + url=self.ppa, + brief="invalid PPA.", + details="PPAs must be non-empty strings.", + resolution=( + "Verify repository configuration and ensure that " + "'ppa' is correctly specified." + ), + ) + + @classmethod + @overrides + def unmarshal(cls, data: Dict[str, str]) -> "PackageRepositoryAptPPA": + """Create a package repository object from the given data.""" + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief="invalid object.", + details="Package repository must be a valid dictionary object.", + resolution=( + "Verify repository configuration and ensure that the correct " + "syntax is used." + ), + ) + + data_copy = deepcopy(data) + + ppa = data_copy.pop("ppa", "") + repo_type = data_copy.pop("type", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution=( + "Verify repository configuration and ensure that 'type' " + "is correctly specified." + ), + ) + + if not isinstance(ppa, str): + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"Invalid PPA {ppa!r}.", + details="PPA must be a valid string.", + resolution=( + "Verify repository configuration and ensure that 'ppa' " + "is correctly specified." + ), + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=ppa, + brief=f"unsupported properties {keys}.", + resolution=( + "Verify repository configuration and ensure that it is correct." + ), + ) + + return cls(ppa=ppa) + + +class PackageRepositoryApt(PackageRepository): + """An APT package repository.""" + + def __init__( + self, + *, + architectures: Optional[List[str]] = None, + components: Optional[List[str]] = None, + formats: Optional[List[str]] = None, + key_id: str, + key_server: Optional[str] = None, + name: Optional[str] = None, + path: Optional[str] = None, + suites: Optional[List[str]] = None, + url: str, + ) -> None: + self.type = "apt" + self.architectures = architectures + self.components = components + self.formats = formats + self.key_id = key_id + self.key_server = key_server + + if name is None: + # Default name is URL, stripping non-alphanumeric characters. + self.name: str = re.sub(r"\W+", "_", url) + else: + self.name = name + + self.path = path + self.suites = suites + self.url = url + + self.validate() + + @overrides + def marshal(self) -> Dict[str, Any]: + """Return the package repository data as a dictionary.""" + data: Dict[str, Any] = {"type": "apt"} + + if self.architectures: + data["architectures"] = self.architectures + + if self.components: + data["components"] = self.components + + if self.formats: + data["formats"] = self.formats + + data["key-id"] = self.key_id + + if self.key_server: + data["key-server"] = self.key_server + + data["name"] = self.name + + if self.path: + data["path"] = self.path + + if self.suites: + data["suites"] = self.suites + + data["url"] = self.url + + return data + + # pylint: disable=too-many-branches + + def validate(self) -> None: # noqa: C901 + """Ensure the current repository data is valid.""" + if self.formats is not None: + for repo_format in self.formats: + if repo_format not in ["deb", "deb-src"]: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"invalid format {repo_format!r}.", + details="Valid formats include: deb and deb-src.", + resolution=( + "Verify the repository configuration and ensure that " + "'formats' is correctly specified." + ), + ) + + if not self.key_id or not re.match(r"^[0-9A-F]{40}$", self.key_id): + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"invalid key identifier {self.key_id!r}.", + details="Key IDs must be 40 upper-case hex characters.", + resolution=( + "Verify the repository configuration and ensure that 'key-id' " + "is correctly specified." + ), + ) + + if not self.url: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="invalid URL.", + details="URLs must be non-empty strings.", + resolution=( + "Verify the repository configuration and ensure that 'url' " + "is correctly specified." + ), + ) + + if self.suites: + for suite in self.suites: + if suite.endswith("/"): + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"invalid suite {suite!r}.", + details="Suites must not end with a '/'.", + resolution=( + "Verify the repository configuration and remove the " + "trailing '/' from suites or use the 'path' property " + "to define a path." + ), + ) + + if self.path is not None and self.path == "": + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=f"invalid path {self.path!r}.", + details="Paths must be non-empty strings.", + resolution=( + "Verify the repository configuration and ensure that 'path' " + "is a non-empty string such as '/'." + ), + ) + + if self.path and self.components: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=( + f"components {self.components!r} cannot be combined with " + f"path {self.path!r}." + ), + details="Path and components are incomptiable options.", + resolution=( + "Verify the repository configuration and remove 'path' " + "or 'components'." + ), + ) + + if self.path and self.suites: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief=( + f"suites {self.suites!r} cannot be combined with " + f"path {self.path!r}." + ), + details="Path and suites are incomptiable options.", + resolution=( + "Verify the repository configuration and remove 'path' or 'suites'." + ), + ) + + if self.suites and not self.components: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="no components specified.", + details="Components are required when using suites.", + resolution=( + "Verify the repository configuration and ensure that 'components' " + "is correctly specified." + ), + ) + + if self.components and not self.suites: + raise errors.PackageRepositoryValidationError( + url=self.url, + brief="no suites specified.", + details="Suites are required when using components.", + resolution=( + "Verify the repository configuration and ensure that 'suites' " + "is correctly specified." + ), + ) + + # pylint: enable=too-many-branches + + @classmethod # noqa: C901 + @overrides + def unmarshal(cls, data: Dict[str, Any]) -> "PackageRepositoryApt": # noqa: C901 + """Create a package repository object from the given data.""" + if not isinstance(data, dict): + raise errors.PackageRepositoryValidationError( + url=str(data), + brief="invalid object.", + details="Package repository must be a valid dictionary object.", + resolution=( + "Verify repository configuration and ensure that the " + "correct syntax is used." + ), + ) + + data_copy = deepcopy(data) + + architectures = data_copy.pop("architectures", None) + components = data_copy.pop("components", None) + formats = data_copy.pop("formats", None) + key_id = data_copy.pop("key-id", None) + key_server = data_copy.pop("key-server", None) + name = data_copy.pop("name", None) + path = data_copy.pop("path", None) + suites = data_copy.pop("suites", None) + url = data_copy.pop("url", "") + repo_type = data_copy.pop("type", None) + + if repo_type != "apt": + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"unsupported type {repo_type!r}.", + details="The only currently supported type is 'apt'.", + resolution=( + "Verify repository configuration and ensure that 'type' " + "is correctly specified." + ), + ) + + if architectures is not None and ( + not isinstance(architectures, list) + or not all(isinstance(x, str) for x in architectures) + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid architectures {architectures!r}.", + details="Architectures must be a list of valid architecture strings.", + resolution=( + "Verify repository configuration and ensure that 'architectures' " + "is correctly specified." + ), + ) + + if components is not None and ( + not isinstance(components, list) + or not all(isinstance(x, str) for x in components) + or not components + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid components {components!r}.", + details="Components must be a list of strings.", + resolution=( + "Verify repository configuration and ensure that 'components' " + "is correctly specified." + ), + ) + + if formats is not None and ( + not isinstance(formats, list) + or not all(isinstance(x, str) for x in formats) + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid formats {formats!r}.", + details="Formats must be a list of strings.", + resolution=( + "Verify repository configuration and ensure that 'formats' " + "is correctly specified." + ), + ) + + if not isinstance(key_id, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid key identifier {key_id!r}.", + details="Key identifiers must be a valid string.", + resolution=( + "Verify repository configuration and ensure that 'key-id' " + "is correctly specified." + ), + ) + + if key_server is not None and not isinstance(key_server, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid key server {key_server!r}.", + details="Key servers must be a valid string.", + resolution=( + "Verify repository configuration and ensure that 'key-server' " + "is correctly specified." + ), + ) + + if name is not None and not isinstance(name, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid name {name!r}.", + details="Names must be a valid string.", + resolution=( + "Verify repository configuration and ensure that 'name' " + "is correctly specified." + ), + ) + + if path is not None and not isinstance(path, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid path {path!r}.", + details="Paths must be a valid string.", + resolution=( + "Verify repository configuration and ensure that 'path' " + "is correctly specified." + ), + ) + + if suites is not None and ( + not isinstance(suites, list) + or not all(isinstance(x, str) for x in suites) + or not suites + ): + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"invalid suites {suites!r}.", + details="Suites must be a list of strings.", + resolution=( + "Verify repository configuration and ensure that 'suites' " + "is correctly specified." + ), + ) + + if not isinstance(url, str): + raise errors.PackageRepositoryValidationError( + url=url, + brief="invalid URL.", + details="URLs must be a valid string.", + resolution=( + "Verify repository configuration and ensure that 'url' " + "is correctly specified." + ), + ) + + if data_copy: + keys = ", ".join([repr(k) for k in data_copy.keys()]) + raise errors.PackageRepositoryValidationError( + url=url, + brief=f"unsupported properties {keys}.", + resolution="Verify repository configuration and ensure it is correct.", + ) + + return cls( + architectures=architectures, + components=components, + formats=formats, + key_id=key_id, + key_server=key_server, + name=name, + suites=suites, + url=url, + ) diff --git a/snapcraft/repo/projects.py b/snapcraft/repo/projects.py new file mode 100644 index 0000000000..8ee59b9d65 --- /dev/null +++ b/snapcraft/repo/projects.py @@ -0,0 +1,95 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2019-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Project model definitions and helpers.""" + +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional + +import pydantic +from pydantic import constr + +# Workaround for mypy +# see https://github.com/samuelcolvin/pydantic/issues/975#issuecomment-551147305 +if TYPE_CHECKING: + KeyIdStr = str +else: + KeyIdStr = constr(regex=r"^[0-9A-F]{40}$") + + +class ProjectModel(pydantic.BaseModel): + """Base model for project repository classes.""" + + class Config: # pylint: disable=too-few-public-methods + """Pydantic model configuration.""" + + validate_assignment = True + allow_mutation = False + allow_population_by_field_name = True + alias_generator = lambda s: s.replace("_", "-") # noqa: E731 + extra = "forbid" + + +# TODO: Project repo definitions are almost the same as PackageRepository +# ported from legacy. Check if we can consolidate them and remove +# field validation (moving all validation rules to pydantic). + + +class AptDeb(ProjectModel): + """Apt package repository definition.""" + + type: Literal["apt"] + url: str + key_id: KeyIdStr + architectures: Optional[List[str]] + formats: Optional[List[Literal["deb", "deb-src"]]] + components: Optional[List[str]] + key_server: Optional[str] + path: Optional[str] + suites: Optional[List[str]] + + @classmethod + def unmarshal(cls, data: Dict[str, Any]) -> "AptDeb": + """Create an AptDeb object from dictionary data.""" + return cls(**data) + + +class AptPPA(ProjectModel): + """PPA package repository definition.""" + + type: Literal["apt"] + ppa: str + + @classmethod + def unmarshal(cls, data: Dict[str, Any]) -> "AptPPA": + """Create an AptPPA object from dictionary data.""" + return cls(**data) + + +def validate_repository(data: Dict[str, Any]): + """Validate a package repository. + + :param data: The repository data to validate. + """ + if not isinstance(data, dict): + raise TypeError("value must be a dictionary") + + try: + AptPPA(**data) + return + except pydantic.ValidationError: + pass + + AptDeb(**data) diff --git a/snapcraft/storeapi/_dashboard_api.py b/snapcraft/storeapi/_dashboard_api.py deleted file mode 100644 index 223a8423c9..0000000000 --- a/snapcraft/storeapi/_dashboard_api.py +++ /dev/null @@ -1,445 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2016-2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import json -import logging -import os -from typing import Any, Dict, Iterable, List, Optional -from urllib.parse import urlencode, urljoin - -import requests -from simplejson.scanner import JSONDecodeError - -from . import _metadata, constants, errors, http_clients, metrics -from ._requests import Requests -from ._status_tracker import StatusTracker -from .v2 import channel_map, releases, validation_sets, whoami - -logger = logging.getLogger(__name__) - - -class DashboardAPI(Requests): - """The Dashboard API is used to publish and manage snaps. - - This is an interface to query that API which is documented - at https://dashboard.snapcraft.io/docs/. - """ - - def __init__(self, auth_client: http_clients.AuthClient) -> None: - self._auth_client = auth_client - self._root_url = os.environ.get( - "STORE_DASHBOARD_URL", constants.STORE_DASHBOARD_URL - ) - - def _request(self, method: str, urlpath: str, **kwargs) -> requests.Response: - url = urljoin(self._root_url, urlpath) - response = self._auth_client.request(method, url, **kwargs) - logger.debug("Call to %s returned: %s", url, response.text) - return response - - def get_macaroon( - self, - *, - acls: Iterable[str], - packages: Optional[Iterable[Dict[str, str]]] = None, - channels: Optional[Iterable[str]] = None, - expires: Optional[Iterable[str]] = None, - ): - data: Dict[str, Any] = {"permissions": acls} - if packages is not None: - data["packages"] = packages - if channels is not None: - data["channels"] = channels - if expires is not None: - data["expires"] = expires - - headers = {"Content-Type": "application/json", "Accept": "application/json"} - - if isinstance(self._auth_client, http_clients.CandidClient): - urlpath = "/api/v2/tokens" - else: - urlpath = "/dev/api/acl/" - - response = self.post(urlpath, json=data, headers=headers, auth_header=False) - - if response.ok: - return response.json()["macaroon"] - else: - raise errors.GeneralStoreError("Failed to get macaroon", response) - - def verify_acl(self): - if not isinstance(self._auth_client, http_clients.UbuntuOneAuthClient): - raise NotImplementedError("Only supports UbuntuOneAuthClient.") - - response = self.post( - "/dev/api/acl/verify/", - json={"auth_data": {"authorization": self._auth_client.auth}}, - headers={"Accept": "application/json"}, - auth_header=False, - ) - if response.ok: - return response.json() - else: - raise errors.StoreAccountInformationError(response) - - def get_account_information(self) -> Dict[str, Any]: - response = self.get("/dev/api/account", headers={"Accept": "application/json"}) - if response.ok: - return response.json() - else: - raise errors.StoreAccountInformationError(response) - - def register_key(self, account_key_request): - data = {"account_key_request": account_key_request} - response = self.post( - "/dev/api/account/account-key", - data=json.dumps(data), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if not response.ok: - raise errors.StoreKeyRegistrationError(response) - - def register( - self, snap_name: str, *, is_private: bool, series: str, store_id: Optional[str] - ) -> None: - data = dict(snap_name=snap_name, is_private=is_private, series=series) - if store_id is not None: - data["store"] = store_id - response = self.post( - "/dev/api/register-name/", - data=json.dumps(data), - headers={"Content-Type": "application/json"}, - ) - if not response.ok: - raise errors.StoreRegistrationError(snap_name, response) - - def snap_upload_precheck(self, snap_name): - data = {"name": snap_name, "dry_run": True} - response = self.post( - "/dev/api/snap-push/", - data=json.dumps(data), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if not response.ok: - raise errors.StoreUploadError(snap_name, response) - - def snap_upload_metadata( - self, - snap_name, - updown_data, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - built_at=None, - channels: Optional[List[str]] = None, - ) -> StatusTracker: - data = { - "name": snap_name, - "series": constants.DEFAULT_SERIES, - "updown_id": updown_data["upload_id"], - "binary_filesize": updown_data["binary_filesize"], - "source_uploaded": updown_data["source_uploaded"], - } - - if delta_format: - data["delta_format"] = delta_format - data["delta_hash"] = delta_hash - data["source_hash"] = source_hash - data["target_hash"] = target_hash - if built_at is not None: - data["built_at"] = built_at - if channels is not None: - data["channels"] = channels - response = self.post( - "/dev/api/snap-push/", - data=json.dumps(data), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if not response.ok: - raise errors.StoreUploadError(data["name"], response) - - return StatusTracker(response.json()["status_details_url"]) - - def upload_metadata(self, snap_id, snap_name, metadata, force): - """Upload the metadata to SCA.""" - metadata_handler = _metadata.StoreMetadataHandler( - request_method=self._request, - snap_id=snap_id, - snap_name=snap_name, - ) - metadata_handler.upload(metadata, force) - - def upload_binary_metadata(self, snap_id, snap_name, metadata, force): - """Upload the binary metadata to SCA.""" - metadata_handler = _metadata.StoreMetadataHandler( - request_method=self._request, - snap_id=snap_id, - snap_name=snap_name, - ) - metadata_handler.upload_binary(metadata, force) - - def snap_release( - self, - snap_name, - revision, - channels, - delta_format=None, - progressive_percentage: Optional[int] = None, - ): - data = {"name": snap_name, "revision": str(revision), "channels": channels} - if delta_format: - data["delta_format"] = delta_format - if progressive_percentage is not None: - data["progressive"] = { - "percentage": progressive_percentage, - "paused": False, - } - response = self.post( - "/dev/api/snap-release/", - data=json.dumps(data), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if not response.ok: - raise errors.StoreReleaseError(data["name"], response) - - response_json = response.json() - - return response_json - - def push_assertion(self, snap_id, assertion, endpoint, force): - if endpoint == "validations": - data = {"assertion": assertion.decode("utf-8")} - elif endpoint == "developers": - data = {"snap_developer": assertion.decode("utf-8")} - else: - raise RuntimeError("No valid endpoint") - - url = "/dev/api/snaps/{}/{}".format(snap_id, endpoint) - - # For `snap-developer`, revoking developers will require their uploads - # to be invalidated. - if force: - url = url + "?ignore_revoked_uploads" - - response = self.put( - url, - json=data, - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - - if not response.ok: - raise errors.StoreValidationError(snap_id, response) - try: - response_json = response.json() - except JSONDecodeError: - message = ( - "Invalid response from the server when pushing validations: {} {}" - ).format(response.status_code, response) - logger.debug(message) - raise errors.StoreValidationError( - snap_id, response, message="Invalid response from the server" - ) - - return response_json - - def get_assertion(self, snap_id, endpoint, params=None): - response = self.get( - f"/dev/api/snaps/{snap_id}/{endpoint}", - headers={"Content-Type": "application/json", "Accept": "application/json"}, - params=params, - ) - if not response.ok: - raise errors.StoreValidationError(snap_id, response) - try: - response_json = response.json() - except JSONDecodeError: - message = "Invalid response from the server when getting {}: {} {}".format( - endpoint, response.status_code, response - ) - logger.debug(message) - raise errors.StoreValidationError( - snap_id, response, message="Invalid response from the server" - ) - - return response_json - - def push_snap_build(self, snap_id, snap_build): - url = f"/dev/api/snaps/{snap_id}/builds" - data = json.dumps({"assertion": snap_build}) - headers = { - "Content-Type": "application/json", - } - response = self.post(url, data=data, headers=headers) - if not response.ok: - raise errors.StoreSnapBuildError(response) - - def snap_status(self, snap_id, series, arch): - qs = {} - if series: - qs["series"] = series - if arch: - qs["architecture"] = arch - url = "/dev/api/snaps/" + snap_id + "/state" - if qs: - url += "?" + urlencode(qs) - response = self.get( - url, - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if not response.ok: - raise errors.StoreSnapStatusError(response, snap_id, series, arch) - - response_json = response.json() - - return response_json - - def close_channels(self, snap_id, channel_names): - url = "/dev/api/snaps/{}/close".format(snap_id) - data = {"channels": channel_names} - headers = {"Content-Type": "application/json", "Accept": "application/json"} - - response = self.post(url, data=json.dumps(data), headers=headers) - if not response.ok: - raise errors.StoreChannelClosingError(response) - - try: - results = response.json() - return results["closed_channels"], results["channel_map_tree"] - except (JSONDecodeError, KeyError): - logger.debug( - "Invalid response from the server on channel closing:\n" - "{} {}\n{}".format( - response.status_code, response.reason, response.content - ) - ) - raise errors.StoreChannelClosingError(response) - - def sign_developer_agreement(self, latest_tos_accepted=False): - data = {"latest_tos_accepted": latest_tos_accepted} - response = self.post( - "/dev/api/agreement/", - json=data, - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - - if not response.ok: - raise errors.DeveloperAgreementSignError(response) - return response.json() - - def get_snap_channel_map(self, *, snap_name: str) -> channel_map.ChannelMap: - response = self.get( - f"/api/v2/snaps/{snap_name}/channel-map", - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - - if not response.ok: - raise errors.StoreSnapChannelMapError(snap_name=snap_name) - - return channel_map.ChannelMap.unmarshal(response.json()) - - def get_metrics( - self, filters: List[metrics.MetricsFilter], snap_name: str - ) -> metrics.MetricsResults: - url = "/dev/api/snaps/metrics" - data = {"filters": [f.marshal() for f in filters]} - headers = {"Content-Type": "application/json", "Accept": "application/json"} - - response = self.post(url, data=json.dumps(data), headers=headers) - if not response.ok: - raise errors.StoreMetricsError( - filters=filters, response=response, snap_name=snap_name - ) - - try: - results = response.json() - return metrics.MetricsResults.unmarshal(results) - except ValueError as error: - raise errors.StoreMetricsUnmarshalError( - filters=filters, snap_name=snap_name, response=response - ) from error - - def get_snap_releases(self, *, snap_name: str) -> releases.Releases: - response = self.get( - f"/api/v2/snaps/{snap_name}/releases", - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - - if not response.ok: - raise errors.StoreSnapChannelMapError(snap_name=snap_name) - - return releases.Releases.unmarshal(response.json()) - - def whoami(self) -> whoami.WhoAmI: - response = self.get( - "/api/v2/tokens/whoami", - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - - if not response.ok: - raise errors.GeneralStoreError(message="whoami failed.", response=response) - - return whoami.WhoAmI.unmarshal(response.json()) - - def post_validation_sets_build_assertion( - self, validation_sets_data: Dict[str, Any] - ) -> validation_sets.BuildAssertion: - url = "/api/v2/validation-sets/build-assertion" - response = self.post( - url, - headers={"Accept": "application/json", "Content-Type": "application/json"}, - json=validation_sets_data, - ) - - if not response.ok: - raise errors.StoreValidationSetsError(response) - - return validation_sets.BuildAssertion.unmarshal(response.json()) - - def post_validation_sets( - self, signed_validation_sets: bytes - ) -> validation_sets.ValidationSets: - url = "/api/v2/validation-sets" - response = self.post( - url, - headers={ - "Accept": "application/json", - "Content-Type": "application/x.ubuntu.assertion", - }, - data=signed_validation_sets, - ) - - if not response.ok: - raise errors.StoreValidationSetsError(response) - - return validation_sets.ValidationSets.unmarshal(response.json()) - - def get_validation_sets( - self, *, name: Optional[str], sequence: Optional[str] - ) -> validation_sets.ValidationSets: - url = "/api/v2/validation-sets" - if name is not None: - url += "/" + name - params = dict() - if sequence is not None: - params["sequence"] = sequence - - response = self.get(url, headers={"Accept": "application/json"}, params=params) - - if not response.ok: - raise errors.StoreValidationSetsError(response) - - return validation_sets.ValidationSets.unmarshal(response.json()) diff --git a/snapcraft/storeapi/_up_down_client.py b/snapcraft/storeapi/_up_down_client.py deleted file mode 100644 index 35b37e2b9c..0000000000 --- a/snapcraft/storeapi/_up_down_client.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -from urllib.parse import urljoin - -import requests - -from ._requests import Requests -from . import constants - - -class UpDownClient(Requests): - """The Up/Down server provide upload/download snap capabilities.""" - - def __init__(self, client) -> None: - self._client = client - self._root_url = os.getenv("STORE_UPLOAD_URL", constants.STORE_UPLOAD_URL) - - def _request(self, method, urlpath, **kwargs) -> requests.Response: - url = urljoin(self._root_url, urlpath) - return self._client.request(method, url, **kwargs) - - def upload(self, monitor): - return self.post( - "/unscanned-upload/", - data=monitor, - headers={ - "Content-Type": monitor.content_type, - "Accept": "application/json", - }, - ) diff --git a/snapcraft/storeapi/_upload.py b/snapcraft/storeapi/_upload.py deleted file mode 100644 index 025f611052..0000000000 --- a/snapcraft/storeapi/_upload.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2016 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import functools -import logging -import os - -from progressbar import Bar, Percentage, ProgressBar -from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor - -from snapcraft.storeapi.errors import StoreUpDownError - -logger = logging.getLogger(__name__) - - -def _update_progress_bar(progress_bar, maximum_value, monitor): - if monitor.bytes_read <= maximum_value: - progress_bar.update(monitor.bytes_read) - - -def upload_files(binary_filename, updown_client): - """Upload a binary file to the Store. - - Submit a file to the Store upload service and return the - corresponding upload_id. - """ - try: - binary_file_size = os.path.getsize(binary_filename) - binary_file = open(binary_filename, "rb") - encoder = MultipartEncoder( - fields={"binary": ("filename", binary_file, "application/octet-stream")} - ) - - # Create a progress bar that looks like: Uploading foo [== ] 50% - progress_bar = ProgressBar( - widgets=[ - "Pushing {!r} ".format(os.path.basename(binary_filename)), - Bar(marker="=", left="[", right="]"), - " ", - Percentage(), - ], - maxval=os.path.getsize(binary_filename), - ) - progress_bar.start() - # Create a monitor for this upload, so that progress can be displayed - monitor = MultipartEncoderMonitor( - encoder, - functools.partial(_update_progress_bar, progress_bar, binary_file_size), - ) - - # Begin upload - response = updown_client.upload(monitor) - - # Make sure progress bar shows 100% complete - progress_bar.finish() - finally: - # Close the open file - binary_file.close() - - if not response.ok: - raise StoreUpDownError(response) - - response_data = response.json() - return { - "upload_id": response_data["upload_id"], - "binary_filesize": binary_file_size, - "source_uploaded": False, - } diff --git a/snapcraft/storeapi/http_clients/_candid_client.py b/snapcraft/storeapi/http_clients/_candid_client.py deleted file mode 100644 index 93de96b389..0000000000 --- a/snapcraft/storeapi/http_clients/_candid_client.py +++ /dev/null @@ -1,160 +0,0 @@ -import base64 -import json -import os -import pathlib -from typing import Optional, TextIO -from urllib.parse import urlparse - -import requests -import macaroonbakery._utils as utils -from macaroonbakery import bakery, httpbakery -from xdg import BaseDirectory - -from snapcraft.storeapi import constants -from . import agent, errors, _config, _http_client - - -class WebBrowserWaitingInteractor(httpbakery.WebBrowserInteractor): - """WebBrowserInteractor implementation using .http_client.Client. - - Waiting for a token is implemented using _http_client.Client which mounts - a session with backoff retires. - - Better exception classes and messages are provided to handle errors. - """ - - # TODO: transfer implementation to macaroonbakery. - def _wait_for_token(self, ctx, wait_token_url): - request_client = _http_client.Client() - resp = request_client.request("GET", wait_token_url) - if resp.status_code != 200: - raise errors.TokenTimeoutError(url=wait_token_url) - json_resp = resp.json() - kind = json_resp.get("kind") - if kind is None: - raise errors.TokenKindError(url=wait_token_url) - token_val = json_resp.get("token") - if token_val is None: - token_val = json_resp.get("token64") - if token_val is None: - raise errors.TokenValueError(url=wait_token_url) - token_val = base64.b64decode(token_val) - return httpbakery._interactor.DischargeToken(kind=kind, value=token_val) - - -class CandidConfig(_config.Config): - """Hold configuration options in sections. - - There can be two sections for the sso related credentials: production and - staging. This is governed by the STORE_DASHBOARD_URL environment - variable. Other sections are ignored but preserved. - - """ - - def _get_section_name(self) -> str: - url = os.getenv("STORE_DASHBOARD_URL", constants.STORE_DASHBOARD_URL) - return urlparse(url).netloc - - def _get_config_path(self) -> pathlib.Path: - return pathlib.Path(BaseDirectory.save_config_path("snapcraft")) / "candid.cfg" - - -class CandidClient(_http_client.Client): - @classmethod - def has_credentials(cls) -> bool: - return not CandidConfig().is_section_empty() - - @property - def _macaroon(self) -> Optional[str]: - return self._conf.get("macaroon") - - @_macaroon.setter - def _macaroon(self, macaroon: str) -> None: - self._conf.set("macaroon", macaroon) - if self._conf_save: - self._conf.save() - - @property - def _auth(self) -> Optional[str]: - return self._conf.get("auth") - - @_auth.setter - def _auth(self, auth: str) -> None: - self._conf.set("auth", auth) - if self._conf_save: - self._conf.save() - - def __init__( - self, *, user_agent: str = agent.get_user_agent(), bakery_client=None - ) -> None: - super().__init__(user_agent=user_agent) - - if bakery_client is None: - self.bakery_client = httpbakery.Client( - interaction_methods=[WebBrowserWaitingInteractor()] - ) - else: - self.bakery_client = bakery_client - self._conf = CandidConfig() - self._conf_save = True - - def _login(self, macaroon: str) -> None: - bakery_macaroon = bakery.Macaroon.from_dict(json.loads(macaroon)) - discharges = bakery.discharge_all( - bakery_macaroon, self.bakery_client.acquire_discharge - ) - - # serialize macaroons the bakery-way - discharged_macaroons = ( - "[" + ",".join(map(utils.macaroon_to_json_string, discharges)) + "]" - ) - - self._auth = base64.urlsafe_b64encode( - utils.to_bytes(discharged_macaroons) - ).decode("ascii") - self._macaroon = macaroon - - def login( - self, - *, - macaroon: Optional[str] = None, - config_fd: Optional[TextIO] = None, - save: bool = True, - ) -> None: - self._conf_save = save - if macaroon is not None: - self._login(macaroon) - elif config_fd is not None: - self._conf.load(config_fd=config_fd) - if save: - self._conf.save() - else: - raise RuntimeError("Logic Error") - - def request( - self, method, url, params=None, headers=None, auth_header=True, **kwargs - ) -> requests.Response: - if headers and auth_header: - headers["Macaroons"] = self._auth - elif auth_header: - headers = {"Macaroons": self._auth} - - response = super().request( - method, url, params=params, headers=headers, **kwargs - ) - - if not response.ok and response.status_code == 401: - self.login(macaroon=self._macaroon) - - response = super().request( - method, url, params=params, headers=headers, **kwargs - ) - - return response - - def export_login(self, *, config_fd: TextIO, encode: bool): - self._conf.save(config_fd=config_fd, encode=encode) - - def logout(self) -> None: - self._conf.clear() - self._conf.save() diff --git a/snapcraft/storeapi/http_clients/_config.py b/snapcraft/storeapi/http_clients/_config.py deleted file mode 100644 index 45ce96570d..0000000000 --- a/snapcraft/storeapi/http_clients/_config.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import abc -import base64 -import io -import os -import pathlib -from typing import Optional, TextIO - -import configparser - -from . import errors - - -class Config(abc.ABC): - def __init__(self) -> None: - self.parser = configparser.ConfigParser() - self.load() - - @abc.abstractmethod - def _get_section_name(self) -> str: - """Return section name.""" - - @abc.abstractmethod - def _get_config_path(self) -> pathlib.Path: - """Return Path to configuration file.""" - - def get( - self, option_name: str, section_name: Optional[str] = None - ) -> Optional[str]: - """Return content of section_name/option_name or None if not found.""" - if section_name is None: - section_name = self._get_section_name() - try: - return self.parser.get(section_name, option_name) - except (configparser.NoSectionError, configparser.NoOptionError, KeyError): - return None - - def set( - self, option_name: str, value: str, section_name: Optional[str] = None - ) -> None: - """Set value to section_name/option_name.""" - if not section_name: - section_name = self._get_section_name() - if not self.parser.has_section(section_name): - self.parser.add_section(section_name) - self.parser.set(section_name, option_name, value) - - def is_section_empty(self, section_name: Optional[str] = None) -> bool: - """Check if section_name is empty.""" - if section_name is None: - section_name = self._get_section_name() - - if self.parser.has_section(section_name): - if self.parser.options(section_name): - return False - return True - - def _load_potentially_base64_config(self, config_content: str) -> None: - try: - self.parser.read_string(config_content) - except configparser.Error as parser_error: - # The config may be base64-encoded, try decoding it - try: - decoded_config_content = base64.b64decode(config_content).decode() - except base64.binascii.Error: # type: ignore - # It wasn't base64, so use the original error - raise errors.InvalidLoginConfig(parser_error) - - try: - self.parser.read_string(decoded_config_content) - except configparser.Error as parser_error: - raise errors.InvalidLoginConfig(parser_error) - - def load(self, *, config_fd: TextIO = None) -> None: - if config_fd is not None: - config_content = config_fd.read() - elif self._get_config_path().exists(): - with self._get_config_path().open() as config_file: - config_content = config_file.read() - else: - return - - self._load_potentially_base64_config(config_content) - - def save(self, *, config_fd: Optional[TextIO] = None, encode: bool = False) -> None: - with io.StringIO() as config_buffer: - self.parser.write(config_buffer) - config_content = config_buffer.getvalue() - if encode: - config_content = base64.b64encode(config_content.encode()).decode() - - if config_fd: - print(config_content, file=config_fd) - else: - with self._get_config_path().open("w") as config_file: - print(config_content, file=config_file) - config_file.flush() - os.fsync(config_file.fileno()) - - def clear(self, section_name: Optional[str] = None) -> None: - if section_name is None: - section_name = self._get_section_name() - - self.parser.remove_section(self._get_section_name()) diff --git a/snapcraft/storeapi/http_clients/_http_client.py b/snapcraft/storeapi/http_clients/_http_client.py deleted file mode 100644 index 0c9f10ff6f..0000000000 --- a/snapcraft/storeapi/http_clients/_http_client.py +++ /dev/null @@ -1,89 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import os -import logging - -import requests -from requests.adapters import HTTPAdapter -from requests.exceptions import ConnectionError, RetryError -from requests.packages.urllib3.util.retry import Retry - -from . import agent, errors - - -# Set urllib3's logger to only emit errors, not warnings. Otherwise even -# retries are printed, and they're nasty. -logging.getLogger(requests.packages.urllib3.__package__).setLevel(logging.ERROR) -logger = logging.getLogger(__name__) - - -class Client: - """Generic Client to talk to the *Store.""" - - def __init__(self, *, user_agent: str = agent.get_user_agent()) -> None: - self.session = requests.Session() - self._user_agent = user_agent - - # Setup max retries for all store URLs and the CDN - retries = Retry( - total=int(os.environ.get("STORE_RETRIES", 5)), - backoff_factor=int(os.environ.get("STORE_BACKOFF", 2)), - status_forcelist=[104, 500, 502, 503, 504], - ) - self.session.mount("http://", HTTPAdapter(max_retries=retries)) - self.session.mount("https://", HTTPAdapter(max_retries=retries)) - - def request( - self, method, url, params=None, headers=None, **kwargs - ) -> requests.Response: - """Send a request to url relative to the root url. - - :param str method: Method used for the request. - :param str url: URL to request with method. - :param list params: Query parameters to be sent along with the request. - :param list headers: Headers to be sent along with the request. - - :return Response of the request. - """ - if headers: - headers["User-Agent"] = self._user_agent - else: - headers = {"User-Agent": self._user_agent} - - debug_headers = headers.copy() - if debug_headers.get("Authorization"): - debug_headers["Authorization"] = "" - if debug_headers.get("Macaroons"): - debug_headers["Macaroons"] = "" - logger.debug( - "Calling {} with params {} and headers {}".format( - url, params, debug_headers - ) - ) - try: - response = self.session.request( - method, url, headers=headers, params=params, **kwargs - ) - except (ConnectionError, RetryError) as e: - raise errors.StoreNetworkError(e) from e - - # Handle 5XX responses generically right here, so the callers don't - # need to worry about it. - if response.status_code >= 500: - raise errors.StoreServerError(response) - - return response diff --git a/snapcraft/storeapi/http_clients/_ubuntu_sso_client.py b/snapcraft/storeapi/http_clients/_ubuntu_sso_client.py deleted file mode 100644 index 604b80f84f..0000000000 --- a/snapcraft/storeapi/http_clients/_ubuntu_sso_client.py +++ /dev/null @@ -1,232 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import json -import os -import pathlib -from typing import Optional, TextIO -from urllib.parse import urljoin, urlparse - -import pymacaroons -import requests -from simplejson.scanner import JSONDecodeError -from xdg import BaseDirectory - -from . import agent, _config, errors, _http_client - - -UBUNTU_ONE_SSO_URL = "https://login.ubuntu.com/" - - -logger = logging.getLogger(__name__) - - -def _deserialize_macaroon(value): - try: - return pymacaroons.Macaroon.deserialize(value) - except: # noqa LP: #1733004 - raise errors.InvalidCredentialsError("Failed to deserialize macaroon") - - -def _macaroon_auth(conf): - """Format a macaroon and its associated discharge. - - :return: A string suitable to use in an Authorization header. - - """ - root_macaroon_raw = conf.get("macaroon") - if root_macaroon_raw is None: - raise errors.InvalidCredentialsError("Root macaroon not in the config file") - unbound_raw = conf.get("unbound_discharge") - if unbound_raw is None: - raise errors.InvalidCredentialsError("Unbound discharge not in the config file") - - root_macaroon = _deserialize_macaroon(root_macaroon_raw) - unbound = _deserialize_macaroon(unbound_raw) - bound = root_macaroon.prepare_for_request(unbound) - discharge_macaroon_raw = bound.serialize() - auth = "Macaroon root={}, discharge={}".format( - root_macaroon_raw, discharge_macaroon_raw - ) - - return auth - - -class UbuntuOneSSOConfig(_config.Config): - """Hold configuration options in sections. - - There can be two sections for the sso related credentials: production and - staging. This is governed by the UBUNTU_ONE_SSO_URL environment - variable. Other sections are ignored but preserved. - - """ - - def _get_section_name(self) -> str: - url = os.getenv("UBUNTU_ONE_SSO_URL", UBUNTU_ONE_SSO_URL) - return urlparse(url).netloc - - def _get_config_path(self) -> pathlib.Path: - return ( - pathlib.Path(BaseDirectory.save_config_path("snapcraft")) / "snapcraft.cfg" - ) - - -class UbuntuOneAuthClient(_http_client.Client): - """Store Client using Ubuntu One SSO provided macaroons.""" - - @staticmethod - def _is_needs_refresh_response(response): - return ( - response.status_code == requests.codes.unauthorized - and response.headers.get("WWW-Authenticate") == "Macaroon needs_refresh=1" - ) - - def __init__(self, *, user_agent: str = agent.get_user_agent()) -> None: - super().__init__(user_agent=user_agent) - - self._conf = UbuntuOneSSOConfig() - self.auth_url = os.environ.get("UBUNTU_ONE_SSO_URL", UBUNTU_ONE_SSO_URL) - - try: - self.auth: Optional[str] = _macaroon_auth(self._conf) - except errors.InvalidCredentialsError: - self.auth = None - - def _extract_caveat_id(self, root_macaroon): - macaroon = pymacaroons.Macaroon.deserialize(root_macaroon) - # macaroons are all bytes, never strings - sso_host = urlparse(self.auth_url).netloc - for caveat in macaroon.caveats: - if caveat.location == sso_host: - return caveat.caveat_id - else: - raise errors.InvalidCredentialsError("Invalid root macaroon") - - def login( - self, - *, - email: Optional[str] = None, - password: Optional[str] = None, - macaroon: Optional[str] = None, - otp: Optional[str] = None, - config_fd: TextIO = None, - save: bool = True, - ) -> None: - if config_fd is not None: - self._conf.load(config_fd=config_fd) - # Verbose to keep static checks happy. - elif email is not None and password is not None and macaroon is not None: - # Ask the store for the needed capabilities to be associated with - # the macaroon. - caveat_id = self._extract_caveat_id(macaroon) - unbound_discharge = self._discharge_token(email, password, otp, caveat_id) - # Clear any old data before setting. - self._conf.clear() - # The macaroon has been discharged, save it in the config - self._conf.set("macaroon", macaroon) - self._conf.set("unbound_discharge", unbound_discharge) - self._conf.set("email", email) - else: - raise RuntimeError("Logic Error") - - # Set auth and headers. - self.auth = _macaroon_auth(self._conf) - - if save: - self._conf.save() - - def export_login(self, *, config_fd: TextIO, encode: bool = False) -> None: - self._conf.save(config_fd=config_fd, encode=encode) - - def logout(self) -> None: - self._conf.clear() - self._conf.save() - - def _discharge_token( - self, email: str, password: str, otp: Optional[str], caveat_id - ) -> str: - data = dict(email=email, password=password, caveat_id=caveat_id) - if otp: - data["otp"] = otp - - url = urljoin(self.auth_url, "/api/v2/tokens/discharge") - - response = self.request( - "POST", - url, - data=json.dumps(data), - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - - if response.ok: - return response.json()["discharge_macaroon"] - - try: - response_json = response.json() - except JSONDecodeError: - response_json = dict() - - if response.status_code == requests.codes.unauthorized and any( - error.get("code") == "twofactor-required" - for error in response_json.get("error_list", []) - ): - raise errors.StoreTwoFactorAuthenticationRequired() - else: - raise errors.StoreAuthenticationError( - "Failed to get unbound discharge", response - ) - - def _refresh_token(self, unbound_discharge): - data = {"discharge_macaroon": unbound_discharge} - url = urljoin(self.auth_url, "/api/v2/tokens/refresh") - response = self.request( - "POST", - url, - json=data, - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if response.ok: - return response.json()["discharge_macaroon"] - else: - raise errors.StoreAuthenticationError( - "Failed to refresh unbound discharge", response - ) - - def request( - self, method, url, params=None, headers=None, auth_header=True, **kwargs - ) -> requests.Response: - if headers and auth_header: - headers["Authorization"] = self.auth - elif auth_header: - headers = {"Authorization": self.auth} - - response = super().request( - method, url, params=params, headers=headers, **kwargs - ) - - if self._is_needs_refresh_response(response): - unbound_discharge = self._refresh_token(self._conf.get("unbound_discharge")) - self._conf.set("unbound_discharge", unbound_discharge) - self._conf.save() - self.auth = _macaroon_auth(self._conf) - headers["Authorization"] = self.auth - - response = super().request( - method, url, params=params, headers=headers, **kwargs - ) - - return response diff --git a/snapcraft/storeapi/http_clients/errors.py b/snapcraft/storeapi/http_clients/errors.py deleted file mode 100644 index 41d2029328..0000000000 --- a/snapcraft/storeapi/http_clients/errors.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import contextlib -import logging -import urllib3 -from simplejson.scanner import JSONDecodeError - -from snapcraft.internal.errors import SnapcraftError - -logger = logging.getLogger(__name__) - - -_STORE_STATUS_URL = "https://status.snapcraft.io/" - - -# TODO: migrate to storeapi private exception to ready craft-store. -class HttpClientError(SnapcraftError): - """Base class http client errors. - - :cvar fmt: A format string that daughter classes override - """ - - def __init__(self, **kwargs): - with contextlib.suppress(KeyError, AttributeError): - logger.debug("Store error response: {}".format(kwargs["response"].__dict__)) - super().__init__(**kwargs) - - -class StoreServerError(HttpClientError): - - fmt = "{what}: {error_text} (code {error_code}).\n{action}" - - def __init__(self, response): - what = "The Snap Store encountered an error while processing your request" - error_code = response.status_code - error_text = response.reason - action = "The operational status of the Snap Store can be checked at {}".format( - _STORE_STATUS_URL - ) - self.response = response - - super().__init__( - response=response, - what=what, - error_text=error_text, - error_code=error_code, - action=action, - ) - - -class StoreNetworkError(HttpClientError): - - fmt = "There seems to be a network error: {message}" - - def __init__(self, exception): - message = str(exception) - with contextlib.suppress(IndexError): - underlying_exception = exception.args[0] - if isinstance(underlying_exception, urllib3.exceptions.MaxRetryError): - message = ( - "maximum retries exceeded trying to reach the store.\n" - "Check your network connection, and check the store " - "status at {}".format(_STORE_STATUS_URL) - ) - super().__init__(message=message) - - -class InvalidCredentialsError(HttpClientError): - - fmt = 'Invalid credentials: {message}. Have you run "snapcraft login"?' - - def __init__(self, message): - super().__init__(message=message) - - -class StoreAuthenticationError(HttpClientError): - - fmt = "Authentication error: {message}" - - def __init__(self, message, response=None): - # Unfortunately the store doesn't give us a consistent error response, - # so we'll check the ones of which we're aware. - with contextlib.suppress(AttributeError, JSONDecodeError): - response_json = response.json() - extra_error_message = "" - if "error_message" in response_json: - extra_error_message = response_json["error_message"] - elif "message" in response_json: - extra_error_message = response_json["message"] - - if extra_error_message: - message += ": {}".format(extra_error_message) - - super().__init__(response=response, message=message) - - -class StoreTwoFactorAuthenticationRequired(StoreAuthenticationError): - def __init__(self): - super().__init__("Two-factor authentication required.") - - -class InvalidLoginConfig(HttpClientError): - - fmt = "Invalid login config: {error}" - - def __init__(self, error): - super().__init__(error=error) - - -class TokenTimeoutError(SnapcraftError): - def __init__(self, *, url: str) -> None: - self.fmt = f"Timed out waiting for token response from {url!r}." - - -class TokenKindError(SnapcraftError): - def __init__(self, *, url: str) -> None: - self.fmt = f"Empty token kind returned from {url!r}." - - -class TokenValueError(SnapcraftError): - def __init__(self, *, url: str) -> None: - self.fmt = f"Empty token value returned from {url!r}." diff --git a/snapcraft/utils.py b/snapcraft/utils.py new file mode 100644 index 0000000000..a7d59f779c --- /dev/null +++ b/snapcraft/utils.py @@ -0,0 +1,242 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2021-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Utilities for snapcraft.""" + +import os +import pathlib +import platform +import sys +from dataclasses import dataclass +from getpass import getpass +from typing import Iterable, Optional + +from craft_cli import emit + +from snapcraft import errors + + +@dataclass +class OSPlatform: + """Platform definition for a given host.""" + + system: str + release: str + machine: str + + def __str__(self) -> str: + """Return the string representation of an OSPlatform.""" + return f"{self.system}/{self.release} ({self.machine})" + + +# translations from what the platform module informs to the term deb and +# snaps actually use +ARCH_TRANSLATIONS = { + "aarch64": "arm64", + "armv7l": "armhf", + "i686": "i386", + "ppc": "powerpc", + "ppc64le": "ppc64el", + "x86_64": "amd64", + "AMD64": "amd64", # Windows support +} + +_32BIT_USERSPACE_ARCHITECTURE = { + "aarch64": "armv7l", + "armv8l": "armv7l", + "ppc64le": "ppc", + "x86_64": "i686", +} + + +def get_os_platform(filepath=pathlib.Path("/etc/os-release")): + """Determine a system/release combo for an OS using /etc/os-release if available.""" + system = platform.system() + release = platform.release() + machine = platform.machine() + + if system == "Linux": + try: + with filepath.open("rt", encoding="utf-8") as release_file: + lines = release_file.readlines() + except FileNotFoundError: + emit.trace("Unable to locate 'os-release' file, using default values") + else: + os_release = {} + for line in lines: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.rstrip().split("=", 1) + if value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + os_release[key] = value + system = os_release.get("ID", system) + release = os_release.get("VERSION_ID", release) + + return OSPlatform(system=system, release=release, machine=machine) + + +def get_host_architecture(): + """Get host architecture in deb format suitable for base definition.""" + os_platform_machine = get_os_platform().machine + + if platform.architecture()[0] == "32bit": + userspace = _32BIT_USERSPACE_ARCHITECTURE.get(os_platform_machine) + if userspace: + os_platform_machine = userspace + + return ARCH_TRANSLATIONS.get(os_platform_machine, os_platform_machine) + + +def strtobool(value: str) -> bool: + """Convert a string representation of truth to true (1) or false (0). + + :param value: a True value of 'y', 'yes', 't', 'true', 'on', and '1' + or a False value of 'n', 'no', 'f', 'false', 'off', and '0'. + :raises ValueError: if `value` is not a valid boolean value. + """ + parsed_value = value.lower() + + if parsed_value in ("y", "yes", "t", "true", "on", "1"): + return True + if parsed_value in ("n", "no", "f", "false", "off", "0"): + return False + + raise ValueError(f"Invalid boolean value of {value!r}") + + +def is_managed_mode() -> bool: + """Check if snapcraft is running in a managed environment.""" + managed_flag = os.getenv("SNAPCRAFT_MANAGED_MODE", "n") + return strtobool(managed_flag) + + +def get_managed_environment_home_path(): + """Path for home when running in managed environment.""" + return pathlib.Path("/root") + + +def get_managed_environment_project_path(): + """Path for project when running in managed environment.""" + return get_managed_environment_home_path() / "project" + + +def get_managed_environment_log_path(): + """Path for log when running in managed environment.""" + return pathlib.Path("/tmp/snapcraft.log") + + +def get_managed_environment_snap_channel() -> Optional[str]: + """User-specified channel to use when installing Snapcraft snap from Snap Store. + + :returns: Channel string if specified, else None. + """ + return os.getenv("SNAPCRAFT_INSTALL_SNAP_CHANNEL") + + +def get_effective_base( + *, + base: Optional[str], + build_base: Optional[str], + project_type: Optional[str], + name: Optional[str], +) -> Optional[str]: + """Return the base to use to create the snap. + + Returns build-base if set, but if not, name is returned if the + snap is of type base. For all other snaps, the base is returned + as the build-base. + """ + if build_base is not None: + return build_base + + return name if project_type == "base" else base + + +def confirm_with_user(prompt_text, default=False) -> bool: + """Query user for yes/no answer. + + If stdin is not a tty, the default value is returned. + + If user returns an empty answer, the default value is returned. + returns default value. + + :returns: True if answer starts with [yY], False if answer starts with [nN], + otherwise the default. + """ + if is_managed_mode(): + raise RuntimeError("confirmation not yet supported in managed-mode") + + if not sys.stdin.isatty(): + return default + + choices = " [Y/n]: " if default else " [y/N]: " + + reply = str(input(prompt_text + choices)).lower().strip() + if reply and reply[0] == "y": + return True + + if reply and reply[0] == "n": + return False + + return default + + +def prompt(prompt_text: str, *, hide: bool = False) -> str: + """Prompt and return the entered string. + + :param prompt_text: string used for the prompt. + :param hide: hide user input if True. + """ + if is_managed_mode(): + raise RuntimeError("prompting not yet supported in managed-mode") + + if not sys.stdin.isatty(): + raise errors.SnapcraftError("prompting not possible with no tty") + + if hide: + method = getpass + else: + method = input # type: ignore + + with emit.pause(): + return str(method(prompt_text)) + + +def humanize_list( + items: Iterable[str], conjunction: str, item_format: str = "{!r}" +) -> str: + """Format a list into a human-readable string. + + :param items: list to humanize. + :param conjunction: the conjunction used to join the final element to + the rest of the list (e.g. 'and'). + :param item_format: format string to use per item. + """ + if not items: + return "" + + quoted_items = [item_format.format(item) for item in sorted(items)] + if len(quoted_items) == 1: + return quoted_items[0] + + humanized = ", ".join(quoted_items[:-1]) + + if len(quoted_items) > 2: + humanized += "," + + return f"{humanized} {conjunction} {quoted_items[-1]}" diff --git a/snapcraft_legacy/__init__.py b/snapcraft_legacy/__init__.py new file mode 100644 index 0000000000..60eaf11c4a --- /dev/null +++ b/snapcraft_legacy/__init__.py @@ -0,0 +1,369 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2017, 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Snapcraft plugins drive different build systems + +Each part has a build system . Most parts are built from source using one of +a range of build systems such as CMake or Scons. Some parts are pre-built +and just copied into place, for example parts that reuse existing binary +packages. + +You tell snapcraft which build system it must drive by specifying the +snapcraft plugin for that part. Every part must specify a plugin explicitly +(when you see a part that does not specify a plugin, that's because the +actual part definition is in the cloud, where the plugin is specified!) + +These plugins implement a lifecycle over the following steps: + + - pull: retrieve the source for the part from the specified location + - build: drive the build system determined by the choice of plugin + - stage: consolidate desirable files from all the parts in one tree + - prime: distill down to only the files which will go into the snap + - snap: compress the prime tree into the installable snap file + +These steps correspond to snapcraft commands. So when you initiate a +'snapcraft pull' you will invoke the respective plugin for each part in +the snap, in sequence, to handle the source pull. Each part will then have a +fully populated parts//src/ directory. Similarly, if you then say +'snapcraft build' you will invoke the plugin responsible for each part in +turn, to build the part. + +# Snapcraft Lifecycle + +## Pull + +In this first step, source material is retrieved from the specified +location, whether that is a URL for a tarball, a local path to a source tree +inside the snap, a revision control reference to checkout, or something +specific to the plugin such as PyPI. The plugin might also download +necessary artifacts, such as the Java SDK, which are not specific to the +particular part but which are needed by the plugin to handle its type of +build system. + +All the downloaded content for each part goes into the +`parts//src/` directory, which acts as a cache to prevent +re-fetching content. You can clean that cache out with 'snapcraft clean'. + +## Build + +Snapcraft calculates an appropriate sequence to build the parts, based on +explicit 'after' references and the order of the parts in the +snapcraft.yaml. Each part is built in the `parts//build` +directory and installed into `parts//install`. + +Note the install step - we might actually want to use built artifacts from +one part in the build process of another, so the `parts//install` +directory is useful as a 'working fresh install' of the part. + +Between the plugin, the part definition YAML, and the build system of the +part, it is expected that the part can be built and installed in the right +place. + +At this point you have a tree under `parts/` with a subdirectory for every +part, and underneath those, separate src, build and install trees for each +part. + +## Stage + +We now need to start consolidating the important pieces of each part into a +single tree. We do this twice - once in a very sweeping way that will +produce a lot of extraneous materials but is useful for debugging. This is +the 'stage' step of the lifecycle, because we move a lot of the build output +from each part into a consolidated tree under `stage/` which has the +structure of a snap but has way too much extra information. + +The important thing about the staging area is that it lets you get all the +shared libraries in one place and lets you find overlapping content in the +parts. You can also try this directory as if it were a snap, and you'll have +all the debugging information in the tree, which is useful for developers. + +Each part describes its own staging content - the files that should be +staged. The part will often describe "chunks" of content, called filesets, +so that they can be referred to as a useful set rather than having to call +out individual files. + +## Prime + +It is useful to have a directory tree which exactly mirrors the structure of +the final snap. This is the `prime/` directory, and the lifecycle includes a +'prime' step which copies only that final, required content from the +`stage/` directory into the `prime/` directory. + +So the `prime/` directory contains only the content that will be put into +the final snap, unlike the staging area which may include debug and +development files not destined for your snap. + +The snap metadata will also be placed in `./prime/meta` during the prime +step, so this `./prime` directory is useful for inspecting exactly what is +going into your snap or to conduct any final post-processing on snapcraft's +output. + +## Snap + +The final step in the snapcraft lifecycle builds a snap out of the `prime/` +directory. It will be in the top level directory, alongside snapcraft.yaml, +called --.snap + + +# Standard part definition keywords + +There are several builtin keywords which can be used in any part regardless +of the choice of plugin. + + - after: [part, part, part...] + + Snapcraft will make sure that it builds all of the listed parts before + it tries to build this part. Essentially these listed dependencies for + this part, useful when the part needs a library or tool built by another + part. + + If such a dependency part is not defined in this snapcraft.yaml, it must + be defined in the cloud parts library, and snapcraft will retrieve the + definition of the part from the cloud. In this way, a shared library of + parts is available to every snap author - just say 'after' and list the + parts you want that others have already defined. + + - build-packages: [pkg, pkg, pkg...] + + A list of packages to install on the build host before building + the part. The files from these packages typically will not go into the + final snap unless they contain libraries that are direct dependencies of + binaries within the snap (in which case they'll be discovered via `ldd`), + or they are explicitly described in stage-packages. + + - stage-packages: YAML list + + A set of packages to be downloaded and unpacked to join the part + before it's built. Note that these packages are not installed on the host. + Like the rest of the part, all files from these packages will make it into + the final snap unless filtered out via the `snap` keyword. + + One may simply specify packages in a flat list, in which case the packages + will be fetched and unpacked regardless of build environment. In addition, + a specific grammar made up of sub-lists is supported here that allows one + to filter stage packages depending on various selectors (e.g. the target + arch), as well as specify optional packages. The grammar is made up of two + nestable statements: 'on' and 'try'. + + Let's discuss `on`. + + - on [,...]: + - ... + - else[ fail]: + - ... + + The body of the 'on' clause is taken into account if every (AND, not OR) + selector is true for the target build environment. Currently the only + selectors supported are target architectures (e.g. amd64). + + If the 'on' clause doesn't match and it's immediately followed by an 'else' + clause, the 'else' clause must be satisfied. An 'on' clause without an + 'else' clause is considered satisfied even if no selector matched. The + 'else fail' form allows erroring out if an 'on' clause was not matched. + + For example, say you only wanted to stage `foo` if building for amd64 (and + not stage `foo` if otherwise): + + - on amd64: [foo] + + Building on that, say you wanted to stage `bar` if building on an arch + other than amd64: + + - on amd64: [foo] + - else: [bar] + + You can nest these for more complex behaviors: + + - on amd64: [foo] + - else: + - on i386: [bar] + - on armhf: [baz] + + If your project requires a package that is only available on amd64, you can + fail if you're not building for amd64: + + - on amd64: [foo] + - else fail + + Now let's discuss `try`: + + - try: + - ... + - else: + - ... + + The body of the 'try' clause is taken into account only when all packages + contained within it are valid. If not, if it's immediately followed by + 'else' clauses they are tried in order, and one of them must be satisfied. + A 'try' clause with no 'else' clause is considered satisfied even if it + contains invalid packages. + + For example, say you wanted to stage `foo`, but it wasn't available for all + architectures. Assuming your project builds without it, you can make it an + optional stage package: + + - try: [foo] + + You can also add alternatives: + + - try: [foo] + - else: [bar] + + Again, you can nest these for more complex behaviors: + + - on amd64: [foo] + - else: + - try: [bar] + + - organize: YAML + + Snapcraft will rename files according to this YAML sub-section. The + content of the 'organize' section consists of old path keys, and their + new values after the renaming. + + This can be used to avoid conflicts between parts that use the same + name, or to map content from different parts into a common conventional + file structure. For example: + + organize: + usr/oldfilename: usr/newfilename + usr/local/share/: usr/share/ + + The key is the internal part filename, the value is the exposed filename + that will be used during the staging process. You can rename whole + subtrees of the part, or just specific files. + + Note that the path is relative (even though it is "usr/local") because + it refers to content underneath parts//install which is going + to be mapped into the stage and prime areas. + + - filesets: YAML + + When we map files into the stage and prime areas on the way to putting + them into the snap, it is convenient to be able to refer to groups of + files as well as individual files. Snapcraft lets you name a fileset + and then use it later for inclusion or exclusion of those files from the + resulting snap. + + For example, consider man pages of header files.. You might want them + in, or you might want to leave them out, but you definitely don't want + to repeatedly have to list all of them either way. + + This section is thus a YAML map of fileset names (the keys) to a list of + filenames. The list is built up by adding individual files or whole + subdirectory paths (and all the files under that path) and wildcard + globs, and then pruning from those paths. + + The wildcard * globs all files in that path. Exclusions are denoted by + an initial `-`. + + For example you could add usr/local/* then remove usr/local/man/*: + + filesets: + allbutman: [ usr/local/*, -usr/local/man/* ] + manpages: [ usr/local/man ] + + Filenames are relative to the part install directory in + `parts//install`. If you have used 'organize' to rename files + then the filesets will be built up from the names after organization. + + - stage: YAML file and fileset list + + A list of files from a part install directory to copy into `stage/`. + Rules applying to the list here are the same as those of filesets. + Referencing of fileset keys is done with a $ prefixing the fileset key, + which will expand with the value of such key. + + For example: + + stage: + - usr/lib/* # Everything under parts//install/usr/lib + - -usr/lib/libtest.so # Excludng libtest.so + - $manpages # Including the 'manpages' fileset + + - snap: YAML file and fileset list + + A list of files from a part install directory to copy into `prime/`. + This section takes exactly the same form as the 'stage' section but the + files identified here will go into the ultimate snap (because the + `prime/` directory reflects the file structure of the snap with no + extraneous content). + + - build-attributes: [attribute1, attribute2] + + A list of special attributes that affect the build of this specific part. + Supported attributes: + + - no-install: + Do not run the install target provided by the plugin's build system. + + Supported by: kbuild + + - debug: + Plugins that support the concept of build types build in Release mode + by default. Setting the 'debug' attribute requests that they instead + build in Debug mode. +""" + +from collections import OrderedDict # noqa + +import pkg_resources # noqa + + +def _get_version(): + import os as _os + + if _os.environ.get("SNAP_NAME") == "snapcraft": + return _os.environ["SNAP_VERSION"] + try: + return pkg_resources.require("snapcraft")[0].version + except pkg_resources.DistributionNotFound: + return "devel" + + +# Set this early so that the circular imports aren't too painful +__version__ = _get_version() + +# Workaround for potential import loops. +from snapcraft_legacy.internal import repo # noqa isort:skip + +# For backwards compatibility with external plugins. +import snapcraft_legacy._legacy_loader # noqa: F401 isort:skip +from snapcraft_legacy.plugins.v1 import PluginV1 as BasePlugin # noqa: F401 isort:skip +from snapcraft_legacy import common # noqa +from snapcraft_legacy import extractors # noqa +from snapcraft_legacy import file_utils # noqa +from snapcraft_legacy import plugins # noqa +from snapcraft_legacy import shell_utils # noqa +from snapcraft_legacy import sources # noqa + +# FIXME LP: #1662658 +from snapcraft_legacy._store import ( # noqa + create_key, + download, + gated, + list_keys, + list_registered, + login, + register, + register_key, + sign_build, + status, + upload_metadata, + validate, +) + +from snapcraft_legacy.project._project_options import ProjectOptions # noqa isort:skip diff --git a/snapcraft/_legacy_loader.py b/snapcraft_legacy/_legacy_loader.py similarity index 85% rename from snapcraft/_legacy_loader.py rename to snapcraft_legacy/_legacy_loader.py index da8c5e081c..ecf9059eeb 100644 --- a/snapcraft/_legacy_loader.py +++ b/snapcraft_legacy/_legacy_loader.py @@ -57,13 +57,13 @@ class LegacyPluginLoader(importlib.abc.Loader): def create_module(cls, spec): # Load the plugin from the new location. plugin_name = spec.name.split(".")[-1] - return importlib.import_module(f"snapcraft.plugins.v1.{plugin_name}") + return importlib.import_module(f"snapcraft_legacy.plugins.v1.{plugin_name}") @classmethod def exec_module(cls, module): # Rewrite the module __name__ to have that of the legacy import path. plugin_name = module.__name__.split(".")[-1] - module.__name__ = f"snapcraft.plugins.{plugin_name}" + module.__name__ = f"snapcraft_legacy.plugins.{plugin_name}" return module @@ -72,8 +72,10 @@ class LegacyPluginPathFinder(importlib.machinery.PathFinder): def find_spec(cls, fullname, path=None, target=None): # Ensure plugins using their original import paths can be found and # warn about their new import path. - if fullname in [f"snapcraft.plugins.{p}" for p in _VALID_V1_PLUGINS]: - warnings.warn("Plugin import path has changed to 'snapcraft.plugins.v1'") + if fullname in [f"snapcraft_legacy.plugins.{p}" for p in _VALID_V1_PLUGINS]: + warnings.warn( + "Plugin import path has changed to 'snapcraft_legacy.plugins.v1'" + ) return importlib.machinery.ModuleSpec(fullname, LegacyPluginLoader) else: return None diff --git a/snapcraft/_store.py b/snapcraft_legacy/_store.py similarity index 70% rename from snapcraft/_store.py rename to snapcraft_legacy/_store.py index 93978fc845..7c435c8d1f 100644 --- a/snapcraft/_store.py +++ b/snapcraft_legacy/_store.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import contextlib -import hashlib import json import logging import operator @@ -23,42 +22,39 @@ import re import subprocess import tempfile -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from subprocess import Popen -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, TextIO, Tuple +from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, Tuple from urllib.parse import urljoin +import craft_store +import requests from tabulate import tabulate -from snapcraft import storeapi, yaml_utils +from snapcraft_legacy import storeapi, yaml_utils # Ideally we would move stuff into more logical components -from snapcraft.cli import echo -from snapcraft.file_utils import ( - calculate_sha3_384, +from snapcraft_legacy.cli import echo +from snapcraft_legacy.file_utils import ( get_host_tool_path, get_snap_tool_path, ) -from snapcraft.internal import cache, deltas -from snapcraft.internal.deltas.errors import ( - DeltaGenerationError, - DeltaGenerationTooBigError, +from snapcraft_legacy.internal.errors import ( + SnapDataExtractionError, + SnapcraftEnvironmentError, ) -from snapcraft.internal.errors import SnapDataExtractionError, ToolMissingError -from snapcraft.storeapi.constants import DEFAULT_SERIES -from snapcraft.storeapi.metrics import MetricsFilter, MetricsResults +from snapcraft_legacy.storeapi.constants import DEFAULT_SERIES +from snapcraft_legacy.storeapi.metrics import MetricsFilter, MetricsResults if TYPE_CHECKING: - from snapcraft.storeapi._status_tracker import StatusTracker - from snapcraft.storeapi.v2.channel_map import ChannelMap - from snapcraft.storeapi.v2.releases import Releases + from snapcraft_legacy.storeapi.v2.releases import Releases logger = logging.getLogger(__name__) -def _get_data_from_snap_file(snap_path): +def get_data_from_snap_file(snap_path): with tempfile.TemporaryDirectory() as temp_dir: unsquashfs_path = get_snap_tool_path("unsquashfs") try: @@ -169,100 +165,115 @@ def _try_login( email: str, password: str, *, - store: storeapi.StoreClient, - save: bool = True, - packages: Iterable[Dict[str, str]] = None, - acls: Iterable[str] = None, - channels: Iterable[str] = None, - expires: str = None, - config_fd: TextIO = None, -) -> None: + store_client: storeapi.StoreClient, + ttl: int, + acls: Optional[Sequence[str]] = None, + packages: Optional[Sequence[str]] = None, + channels: Optional[Sequence[str]] = None, +) -> str: try: - store.login( + credentials = store_client.login( email=email, password=password, - packages=packages, + ttl=ttl, acls=acls, + packages=packages, channels=channels, - expires=expires, - config_fd=config_fd, - save=save, ) - if not config_fd: - print() - echo.wrapped(storeapi.constants.TWO_FACTOR_WARNING) - except storeapi.http_clients.errors.StoreTwoFactorAuthenticationRequired: + print() + echo.wrapped(storeapi.constants.TWO_FACTOR_WARNING) + except craft_store.errors.StoreServerError as store_error: + if "twofactor-required" not in store_error.error_list: + raise one_time_password = echo.prompt("Second-factor auth") - store.login( + credentials = store_client.login( email=email, password=password, otp=one_time_password, + ttl=ttl, acls=acls, packages=packages, channels=channels, - expires=expires, - config_fd=config_fd, - save=save, ) - # Continue if agreement and namespace conditions are met. - _check_dev_agreement_and_namespace_statuses(store) + return credentials + + +def _prompt_login() -> Tuple[str, str]: + echo.wrapped("Enter your Ubuntu One e-mail address and password.") + echo.wrapped( + "If you do not have an Ubuntu One account, you can create one " + "at https://snapcraft.io/account" + ) + email = echo.prompt("Email") + if os.getenv("SNAPCRAFT_TEST_INPUT"): + # Integration tests do not work well with hidden input. + echo.warning("Password will be visible.") + hide_input = False + else: + hide_input = True + password = echo.prompt("Password", hide_input=hide_input) + + return (email, password) def login( *, - store: storeapi.StoreClient, - packages: Iterable[Dict[str, str]] = None, - save: bool = True, - acls: Iterable[str] = None, - channels: Iterable[str] = None, - expires: str = None, - config_fd: TextIO = None, -) -> bool: - if not store: - store = storeapi.StoreClient() - - email = "" - password = "" - - if not config_fd: - echo.wrapped("Enter your Ubuntu One e-mail address and password.") - echo.wrapped( - "If you do not have an Ubuntu One account, you can create one " - "at https://snapcraft.io/account" + store_client: storeapi.StoreClient, + ttl: int = int(timedelta(days=365).total_seconds()), + acls: Optional[Sequence[str]] = None, + packages: Optional[Sequence[str]] = None, + channels: Optional[Sequence[str]] = None, +) -> str: + if store_client.use_candid() is True: + credentials = store_client.login( + ttl=ttl, + acls=acls, + channels=channels, + packages=packages, ) - email = echo.prompt("Email") - if os.getenv("SNAPCRAFT_TEST_INPUT"): - # Integration tests do not work well with hidden input. - echo.warning("Password will be visible.") - hide_input = False - else: - hide_input = True - password = echo.prompt("Password", hide_input=hide_input) - - _try_login( - email, - password, - store=store, - packages=packages, - acls=acls, - channels=channels, - expires=expires, - config_fd=config_fd, - save=save, - ) + else: + email, password = _prompt_login() - return True + credentials = _try_login( + email, + password, + store_client=store_client, + ttl=ttl, + packages=packages, + acls=acls, + channels=channels, + ) + + # Continue if agreement and namespace conditions are met. + _check_dev_agreement_and_namespace_statuses(store_client) + + return credentials def _login_wrapper(method): def login_decorator(self, *args, **kwargs): try: return method(self, *args, **kwargs) - except storeapi.http_clients.errors.InvalidCredentialsError: - print("You are required to login before continuing.") - login(store=self) - return method(self, *args, **kwargs) + except craft_store.errors.StoreServerError as store_error: + if ( + store_error.response.status_code == requests.codes.unauthorized + and not os.getenv(storeapi.constants.ENVIRONMENT_STORE_CREDENTIALS) + ): + self.logout() + echo.info("You are required to login before continuing.") + login(store_client=self) + return method(self, *args, **kwargs) + elif ( + store_error.response.status_code == requests.codes.unauthorized + and not os.getenv(storeapi.constants.ENVIRONMENT_STORE_CREDENTIALS) + ): + raise SnapcraftEnvironmentError( + "Provided credentials are no longer valid for the Snap Store. " + "Regenerate them and try again." + ) from store_error + else: + raise return login_decorator @@ -301,26 +312,20 @@ class StoreClientCLI(storeapi.StoreClient): # features are developed for them, but still provide a simple wrapper # method around those methods for backwards compatibility. # - # This class can be thought of and extension to snapcraft.cli.store. - # It just lives in snapcraft._store due to the convenience of the + # This class can be thought of and extension to snapcraft_legacy.cli.store. + # It just lives in snapcraft_legacy._store due to the convenience of the # methods it is trying to replace. Considering this is a private module - # and this class is not exported, moving it to snapcraft.cli can take + # and this class is not exported, moving it to snapcraft_legacy.cli can take # place. # # This is the list of items that needs to be tackled to get to there: # - # TODO create an internal copy of snapcraft.storeapi + # TODO create an internal copy of snapcraft_legacy.storeapi # TODO move configuration loading to this class and out of - # snapcraft.storeapi.StoreClient - # TODO Move progressbar implementation out of snapcraft.storeapi used + # snapcraft_legacy.storeapi.StoreClient + # TODO Move progressbar implementation out of snapcraft_legacy.storeapi used # during upload into this class using click. - # TODO use an instance of this class directly from snapcraft.cli.store - - @_login_wrapper - def close_channels( - self, *, snap_id: str, channel_names: List[str] - ) -> Dict[str, Any]: - return super().close_channels(snap_id=snap_id, channel_names=channel_names) + # TODO use an instance of this class directly from snapcraft_legacy.cli.store @_login_wrapper def get_metrics( @@ -332,10 +337,6 @@ def get_metrics( def get_snap_releases(self, *, snap_name: str) -> "Releases": return super().get_snap_releases(snap_name=snap_name) - @_login_wrapper - def get_snap_channel_map(self, *, snap_name: str) -> "ChannelMap": - return super().get_snap_channel_map(snap_name=snap_name) - @_login_wrapper def get_account_information(self) -> Dict[str, Any]: return super().get_account_information() @@ -364,47 +365,6 @@ def register( ) -> None: super().register(snap_name=snap_name, is_private=is_private, store_id=store_id) - @_login_wrapper - def release( - self, - *, - snap_name: str, - revision: str, - channels: List[str], - progressive_percentage: Optional[int] = None, - ) -> Dict[str, Any]: - return super().release( - snap_name=snap_name, - revision=revision, - channels=channels, - progressive_percentage=progressive_percentage, - ) - - @_login_wrapper - @_register_wrapper - def upload( - self, - *, - snap_name: str, - snap_filename: str, - built_at: Optional[str] = None, - channels: Optional[List[str]] = None, - delta_format: Optional[str] = None, - source_hash: Optional[str] = None, - target_hash: Optional[str] = None, - delta_hash: Optional[str] = None, - ) -> "StatusTracker": - return super().upload( - snap_name=snap_name, - snap_filename=snap_filename, - built_at=built_at, - channels=channels, - delta_format=delta_format, - source_hash=source_hash, - target_hash=target_hash, - delta_hash=delta_hash, - ) - def list_registered(): account_info = StoreClientCLI().get_account_information() @@ -525,11 +485,14 @@ def create_key(name): enabled_names = { account_key["name"] for account_key in account_info["account_keys"] } - except storeapi.http_clients.errors.InvalidCredentialsError: - # Don't require a login here; if they don't have valid credentials, - # then they probably also don't have a key registered with the store - # yet. - enabled_names = set() + except craft_store.errors.StoreServerError as store_error: + if store_error.response.status_code == 401: + # Don't require a login here; if they don't have valid credentials, + # then they probably also don't have a key registered with the store + # yet. + enabled_names = set() + else: + raise if name in enabled_names: raise storeapi.errors.KeyAlreadyRegisteredError(name) subprocess.check_call(["snap", "create-key", name]) @@ -545,15 +508,15 @@ def _maybe_prompt_for_key(name): return _select_key(keys) -def register_key(name, use_candid: bool = False) -> None: +def register_key(name) -> None: key = _maybe_prompt_for_key(name) - store_client = StoreClientCLI(use_candid=use_candid) - # TODO: remove coupling. - if isinstance(store_client.auth_client, storeapi.http_clients.CandidClient): - store_client.login(acls=["modify_account_key"], save=False) - else: - login(store=store_client, acls=["modify_account_key"], save=False) + store_client = StoreClientCLI(ephemeral=True) + login( + store_client=store_client, + acls=["modify_account_key"], + ttl=int(timedelta(days=1).total_seconds()), + ) logger.info("Registering key ...") account_info = store_client.get_account_information() @@ -596,7 +559,7 @@ def sign_build(snap_filename, key_name=None, local=False): if not os.path.exists(snap_filename): raise FileNotFoundError("The file {!r} does not exist.".format(snap_filename)) - snap_yaml = _get_data_from_snap_file(snap_filename) + snap_yaml = get_data_from_snap_file(snap_filename) snap_name = snap_yaml["name"] grade = snap_yaml.get("grade", "stable") @@ -647,7 +610,7 @@ def upload_metadata(snap_filename, force): logger.debug("Uploading metadata to the Store (force=%s)", force) # get the metadata from the snap - snap_yaml = _get_data_from_snap_file(snap_filename) + snap_yaml = get_data_from_snap_file(snap_filename) metadata = { "summary": snap_yaml["summary"], "description": snap_yaml["description"], @@ -674,160 +637,6 @@ def upload_metadata(snap_filename, force): logger.info("The metadata has been uploaded") -def upload(snap_filename, release_channels=None) -> Tuple[str, int]: - """Upload a snap_filename to the store. - - If a cached snap is available, a delta will be generated from - the cached snap to the new target snap and uploaded instead. In the - case of a delta processing or upload failure, upload will fall back to - uploading the full snap. - - If release_channels is defined it also releases it to those channels if the - store deems the uploaded snap as ready to release. - """ - snap_yaml = _get_data_from_snap_file(snap_filename) - snap_name = snap_yaml["name"] - built_at = snap_yaml.get("snapcraft-started-at") - - logger.debug( - "Run upload precheck and verify cached data for {!r}.".format(snap_filename) - ) - store_client = StoreClientCLI() - store_client.upload_precheck(snap_name=snap_name) - - snap_cache = cache.SnapCache(project_name=snap_name) - - try: - deb_arch = snap_yaml["architectures"][0] - except KeyError: - deb_arch = "all" - - source_snap = snap_cache.get(deb_arch=deb_arch) - sha3_384_available = hasattr(hashlib, "sha3_384") - - result: Optional[Dict[str, Any]] = None - if sha3_384_available and source_snap: - try: - result = _upload_delta( - store_client, - snap_name=snap_name, - snap_filename=snap_filename, - source_snap=source_snap, - built_at=built_at, - channels=release_channels, - ) - except storeapi.errors.StoreDeltaApplicationError as e: - logger.warning( - "Error generating delta: {}\n" - "Falling back to uploading full snap...".format(str(e)) - ) - except storeapi.errors.StoreUploadError as upload_error: - logger.warning( - "Unable to upload delta to store: {}\n" - "Falling back to uploading full snap...".format(upload_error.error_list) - ) - - if result is None: - result = _upload_snap( - store_client, - snap_name=snap_name, - snap_filename=snap_filename, - built_at=built_at, - channels=release_channels, - ) - - snap_cache.cache(snap_filename=snap_filename) - snap_cache.prune(deb_arch=deb_arch, keep_hash=calculate_sha3_384(snap_filename)) - - return snap_name, result["revision"] - - -def _upload_snap( - store_client, - *, - snap_name: str, - snap_filename: str, - built_at: str, - channels: Optional[List[str]], -) -> Dict[str, Any]: - tracker = store_client.upload( - snap_name=snap_name, - snap_filename=snap_filename, - built_at=built_at, - channels=channels, - ) - result = tracker.track() - tracker.raise_for_code() - return result - - -def _upload_delta( - store_client, - *, - snap_name: str, - snap_filename: str, - source_snap: str, - built_at: str, - channels: Optional[List[str]] = None, -) -> Dict[str, Any]: - delta_format = "xdelta3" - logger.debug("Found cached source snap {}.".format(source_snap)) - target_snap = os.path.join(os.getcwd(), snap_filename) - - try: - xdelta_generator = deltas.XDelta3Generator( - source_path=source_snap, target_path=target_snap - ) - delta_filename = xdelta_generator.make_delta() - except (DeltaGenerationError, DeltaGenerationTooBigError, ToolMissingError) as e: - raise storeapi.errors.StoreDeltaApplicationError(str(e)) - - snap_hashes = { - "source_hash": calculate_sha3_384(source_snap), - "target_hash": calculate_sha3_384(target_snap), - "delta_hash": calculate_sha3_384(delta_filename), - } - - try: - logger.debug("Uploading delta {!r}.".format(delta_filename)) - delta_tracker = store_client.upload( - snap_name=snap_name, - snap_filename=delta_filename, - built_at=built_at, - channels=channels, - delta_format=delta_format, - source_hash=snap_hashes["source_hash"], - target_hash=snap_hashes["target_hash"], - delta_hash=snap_hashes["delta_hash"], - ) - result = delta_tracker.track() - delta_tracker.raise_for_code() - except storeapi.errors.StoreReviewError as e: - if e.code == "processing_upload_delta_error": - raise storeapi.errors.StoreDeltaApplicationError(str(e)) - else: - raise - except storeapi.http_clients.errors.StoreServerError as e: - raise storeapi.errors.StoreUploadError(snap_name, e.response) - finally: - if os.path.isfile(delta_filename): - try: - os.remove(delta_filename) - except OSError: - logger.warning("Unable to remove delta {}.".format(delta_filename)) - return result - - -def _get_text_for_opened_channels(opened_channels): - if len(opened_channels) == 1: - return "The {!r} channel is now open.".format(opened_channels[0]) - else: - channels = ("{!r}".format(channel) for channel in opened_channels[:-1]) - return "The {} and {!r} channels are now open.".format( - ", ".join(channels), opened_channels[-1] - ) - - def _get_text_for_channel(channel): if "progressive" in channel: notes = "progressive ({}%)".format(channel["progressive"]["percentage"]) @@ -931,7 +740,7 @@ def download( hash. :returns: A sha3_384 of the file that was or would have been downloaded. """ - return StoreClientCLI().download( + return StoreClientCLI.download( snap_name, risk=risk, track=track, diff --git a/snapcraft/cli/__init__.py b/snapcraft_legacy/cli/__init__.py similarity index 89% rename from snapcraft/cli/__init__.py rename to snapcraft_legacy/cli/__init__.py index 7c8e9889e3..c2aed0019d 100644 --- a/snapcraft/cli/__init__.py +++ b/snapcraft_legacy/cli/__init__.py @@ -13,6 +13,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.internal.dirs +import snapcraft_legacy.internal.dirs -snapcraft.internal.dirs.setup_dirs() +snapcraft_legacy.internal.dirs.setup_dirs() diff --git a/snapcraft/cli/__main__.py b/snapcraft_legacy/cli/__main__.py similarity index 89% rename from snapcraft/cli/__main__.py rename to snapcraft_legacy/cli/__main__.py index 58308cfe8a..be854a0d25 100644 --- a/snapcraft/cli/__main__.py +++ b/snapcraft_legacy/cli/__main__.py @@ -20,9 +20,9 @@ import os import subprocess -from snapcraft.cli._runner import run -from snapcraft.cli.echo import warning -from snapcraft.cli.snapcraftctl._runner import run as run_snapcraftctl # noqa +from snapcraft_legacy.cli._runner import run +from snapcraft_legacy.cli.echo import warning +from snapcraft_legacy.cli.snapcraftctl._runner import run as run_snapcraftctl # noqa # If the locale ends up being ascii, Click will barf. Let's try to prevent that # here by using C.UTF-8 as a last-resort fallback. This mostly happens in CI, diff --git a/snapcraft/cli/_channel_map.py b/snapcraft_legacy/cli/_channel_map.py similarity index 99% rename from snapcraft/cli/_channel_map.py rename to snapcraft_legacy/cli/_channel_map.py index bbb8824adf..6f01bb3974 100644 --- a/snapcraft/cli/_channel_map.py +++ b/snapcraft_legacy/cli/_channel_map.py @@ -22,7 +22,7 @@ from tabulate import tabulate from typing_extensions import Final -from snapcraft.storeapi.v2.channel_map import ( +from snapcraft_legacy.storeapi.v2.channel_map import ( ChannelMap, MappedChannel, Revision, diff --git a/snapcraft/cli/_command_group.py b/snapcraft_legacy/cli/_command_group.py similarity index 97% rename from snapcraft/cli/_command_group.py rename to snapcraft_legacy/cli/_command_group.py index 66de50408c..67423d1a18 100644 --- a/snapcraft/cli/_command_group.py +++ b/snapcraft_legacy/cli/_command_group.py @@ -15,7 +15,7 @@ # along with this program. If not, see . import click -from snapcraft.internal import deprecations +from snapcraft_legacy.internal import deprecations from . import echo diff --git a/snapcraft/cli/_errors.py b/snapcraft_legacy/cli/_errors.py similarity index 95% rename from snapcraft/cli/_errors.py rename to snapcraft_legacy/cli/_errors.py index 98176bc3a7..7eb72c2ed5 100644 --- a/snapcraft/cli/_errors.py +++ b/snapcraft_legacy/cli/_errors.py @@ -23,12 +23,13 @@ from typing import Dict import click +import craft_store from raven import Client as RavenClient from raven.transport import RequestsHTTPTransport -import snapcraft -from snapcraft.config import CLIConfig as _CLIConfig -from snapcraft.internal import errors +import snapcraft_legacy +from snapcraft_legacy.config import CLIConfig as _CLIConfig +from snapcraft_legacy.internal import errors from . import echo @@ -84,8 +85,10 @@ def _is_reportable_error(exc_info) -> bool: return exc_info[1].get_reportable() # Report non-snapcraft errors. - if not issubclass(exc_info[0], errors.SnapcraftError) and not isinstance( - exc_info[1], KeyboardInterrupt + if ( + not issubclass(exc_info[0], errors.SnapcraftError) + and not issubclass(exc_info[0], craft_store.errors.CraftStoreError) + and not isinstance(exc_info[1], KeyboardInterrupt) ): return True @@ -110,7 +113,7 @@ def _is_printable_traceback(exc_info, debug) -> bool: return True # Print if not using snap. - if not snapcraft.internal.common.is_snap(): + if not snapcraft_legacy.internal.common.is_snap(): return True return False @@ -122,7 +125,7 @@ def _handle_sentry_submission(exc_info) -> None: return # Only attempt if running as snap (with Raven requirement). - if not snapcraft.internal.common.is_snap(): + if not snapcraft_legacy.internal.common.is_snap(): # Suggest manual reporting instead. click.echo(_MSG_MANUALLY_REPORT) return @@ -259,7 +262,7 @@ def exception_handler( # noqa: C901 (a) no TTY (b) not running as snap - - Use exit code from snapcraft error (if available), otherwise 1. + - Use exit code from snapcraft_legacy error (if available), otherwise 1. """ exit_code = _get_exception_exit_code(exception) exc_info = (exception_type, exception, exception_traceback) @@ -361,8 +364,8 @@ def validate(value): def _submit_trace(exc_info): kwargs: Dict[str, str] = dict() - if "+git" not in snapcraft.__version__: - kwargs["release"] = snapcraft.__version__ + if "+git" not in snapcraft_legacy.__version__: + kwargs["release"] = snapcraft_legacy.__version__ client = RavenClient( "https://b0fef3e0ced2443c92143ae0d038b0a4:" diff --git a/snapcraft/cli/_metrics.py b/snapcraft_legacy/cli/_metrics.py similarity index 98% rename from snapcraft/cli/_metrics.py rename to snapcraft_legacy/cli/_metrics.py index e858017575..a58f15edc6 100644 --- a/snapcraft/cli/_metrics.py +++ b/snapcraft_legacy/cli/_metrics.py @@ -19,7 +19,7 @@ import pkg_resources -from snapcraft.storeapi import metrics as metrics_module +from snapcraft_legacy.storeapi import metrics as metrics_module logger = logging.getLogger(__name__) diff --git a/snapcraft/cli/_options.py b/snapcraft_legacy/cli/_options.py similarity index 98% rename from snapcraft/cli/_options.py rename to snapcraft_legacy/cli/_options.py index a1492c878c..d634ca4f45 100644 --- a/snapcraft/cli/_options.py +++ b/snapcraft_legacy/cli/_options.py @@ -21,10 +21,10 @@ import click -from snapcraft.cli.echo import confirm, prompt, warning -from snapcraft.internal import common, errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.project import Project, get_snapcraft_yaml +from snapcraft_legacy.cli.echo import confirm, prompt, warning +from snapcraft_legacy.internal import common, errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.project import Project, get_snapcraft_yaml class PromptOption(click.Option): diff --git a/snapcraft/cli/_review.py b/snapcraft_legacy/cli/_review.py similarity index 96% rename from snapcraft/cli/_review.py rename to snapcraft_legacy/cli/_review.py index 76f69a0a68..e7690dfa93 100644 --- a/snapcraft/cli/_review.py +++ b/snapcraft_legacy/cli/_review.py @@ -18,7 +18,7 @@ import click -from snapcraft.internal import review_tools +from snapcraft_legacy.internal import review_tools from . import echo diff --git a/snapcraft/cli/_runner.py b/snapcraft_legacy/cli/_runner.py similarity index 94% rename from snapcraft/cli/_runner.py rename to snapcraft_legacy/cli/_runner.py index e38e9a310d..326cb43119 100644 --- a/snapcraft/cli/_runner.py +++ b/snapcraft_legacy/cli/_runner.py @@ -21,8 +21,8 @@ import click -import snapcraft -from snapcraft.internal import log +import snapcraft_legacy +from snapcraft_legacy.internal import log from ._command_group import SnapcraftGroup from ._errors import exception_handler @@ -86,7 +86,7 @@ def configure_requests_ca() -> None: context_settings=dict(help_option_names=["-h", "--help"]), ) @click.version_option( - message=SNAPCRAFT_VERSION_TEMPLATE, version=snapcraft.__version__ # type: ignore + message=SNAPCRAFT_VERSION_TEMPLATE, version=snapcraft_legacy.__version__ # type: ignore ) @click.pass_context @add_provider_options(hidden=True) @@ -99,7 +99,7 @@ def run(ctx, debug, catch_exceptions=False, **kwargs): log_level = logging.DEBUG click.echo( "Starting snapcraft {} from {}.".format( - snapcraft.__version__, os.path.dirname(__file__) + snapcraft_legacy.__version__, os.path.dirname(__file__) ) ) else: diff --git a/snapcraft/cli/assertions.py b/snapcraft_legacy/cli/assertions.py similarity index 92% rename from snapcraft/cli/assertions.py rename to snapcraft_legacy/cli/assertions.py index 2cd3e9d114..5020480aba 100644 --- a/snapcraft/cli/assertions.py +++ b/snapcraft_legacy/cli/assertions.py @@ -15,7 +15,7 @@ # along with this program. If not, see . import os import json -from snapcraft.internal.errors import details_from_command_error +from snapcraft_legacy.internal.errors import details_from_command_error import subprocess import tempfile from datetime import datetime @@ -25,9 +25,9 @@ import click from tabulate import tabulate -import snapcraft -from snapcraft._store import StoreClientCLI -from snapcraft import yaml_utils +import snapcraft_legacy +from snapcraft_legacy._store import StoreClientCLI +from snapcraft_legacy import storeapi, yaml_utils from . import echo @@ -65,14 +65,14 @@ def list_keys(): This command has an alias of `keys`. """ - snapcraft.list_keys() + snapcraft_legacy.list_keys() @assertionscli.command("create-key") @click.argument("key-name", metavar="", required=False) def create_key(key_name: str) -> None: """Create a key to sign assertions.""" - snapcraft.create_key(key_name) + snapcraft_legacy.create_key(key_name) @assertionscli.command("register-key") @@ -85,7 +85,11 @@ def create_key(key_name: str) -> None: ) def register_key(key_name: str, experimental_login: bool) -> None: """Register a key with the store to sign assertions.""" - snapcraft.register_key(key_name, use_candid=experimental_login) + if experimental_login: + raise click.BadArgumentUsage( + f"Set {storeapi.constants.ENVIRONMENT_STORE_AUTH}=candid instead" + ) + snapcraft_legacy.register_key(key_name) @assertionscli.command("sign-build") @@ -100,7 +104,7 @@ def register_key(key_name: str, experimental_login: bool) -> None: ) def sign_build(snap_file: str, key_name: str, local: bool) -> None: """Sign a built snap file and assert it using the developer's key.""" - snapcraft.sign_build(snap_file, key_name=key_name, local=local) + snapcraft_legacy.sign_build(snap_file, key_name=key_name, local=local) @assertionscli.command() @@ -116,14 +120,14 @@ def validate(snap_name: str, validations: list, key_name: str, revoke: bool) -> - = - = """ - snapcraft.validate(snap_name, validations, revoke=revoke, key=key_name) + snapcraft_legacy.validate(snap_name, validations, revoke=revoke, key=key_name) @assertionscli.command() @click.argument("snap-name", metavar="") def gated(snap_name: str) -> None: """Get the list of snaps and revisions gating a snap.""" - snapcraft.gated(snap_name) + snapcraft_legacy.gated(snap_name) @assertionscli.command("list-validation-sets") diff --git a/snapcraft/cli/containers.py b/snapcraft_legacy/cli/containers.py similarity index 95% rename from snapcraft/cli/containers.py rename to snapcraft_legacy/cli/containers.py index 64bcd3f2c8..bfe5b578ac 100644 --- a/snapcraft/cli/containers.py +++ b/snapcraft_legacy/cli/containers.py @@ -16,7 +16,7 @@ import click -from snapcraft.internal import repo +from snapcraft_legacy.internal import repo @click.group() diff --git a/snapcraft/cli/discovery.py b/snapcraft_legacy/cli/discovery.py similarity index 83% rename from snapcraft/cli/discovery.py rename to snapcraft_legacy/cli/discovery.py index 71c0527488..898a27ad2a 100644 --- a/snapcraft/cli/discovery.py +++ b/snapcraft_legacy/cli/discovery.py @@ -19,10 +19,13 @@ import click -import snapcraft -from snapcraft.internal import errors -from snapcraft.internal.common import format_output_in_columns, get_terminal_width -from snapcraft.project import errors as project_errors +import snapcraft_legacy +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.common import ( + format_output_in_columns, + get_terminal_width, +) +from snapcraft_legacy.project import errors as project_errors from ._options import get_project @@ -44,9 +47,9 @@ def _try_get_base_from_project() -> str: def _get_modules_iter(base: str) -> Iterable: if base == "core18": - modules_path = snapcraft.plugins.v1.__path__ # type: ignore # mypy issue #1422 + modules_path = snapcraft_legacy.plugins.v1.__path__ # type: ignore # mypy issue #1422 else: - modules_path = snapcraft.plugins.v2.__path__ # type: ignore # mypy issue #1422 + modules_path = snapcraft_legacy.plugins.v2.__path__ # type: ignore # mypy issue #1422 # TODO make this part of plugin_finder. return pkgutil.iter_modules(modules_path) diff --git a/snapcraft/cli/echo.py b/snapcraft_legacy/cli/echo.py similarity index 97% rename from snapcraft/cli/echo.py rename to snapcraft_legacy/cli/echo.py index 575b7abaa7..1399b0e07a 100644 --- a/snapcraft/cli/echo.py +++ b/snapcraft_legacy/cli/echo.py @@ -26,7 +26,7 @@ import click -from snapcraft.internal import common +from snapcraft_legacy.internal import common def is_tty_connected() -> bool: @@ -41,7 +41,7 @@ def wrapped(msg: str) -> None: """Output msg wrapped to the terminal width to stdout. The maximum wrapping is determined by - snapcraft.internal.common.MAX_CHARACTERS_WRAP + snapcraft_legacy.internal.common.MAX_CHARACTERS_WRAP """ click.echo( click.formatting.wrap_text( diff --git a/snapcraft/cli/extensions.py b/snapcraft_legacy/cli/extensions.py similarity index 96% rename from snapcraft/cli/extensions.py rename to snapcraft_legacy/cli/extensions.py index c0f58bb9ee..f3999efa5b 100644 --- a/snapcraft/cli/extensions.py +++ b/snapcraft_legacy/cli/extensions.py @@ -22,8 +22,8 @@ import click import tabulate -from snapcraft import yaml_utils -from snapcraft.internal import project_loader +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal import project_loader from ._options import get_project diff --git a/snapcraft/cli/help.py b/snapcraft_legacy/cli/help.py similarity index 93% rename from snapcraft/cli/help.py rename to snapcraft_legacy/cli/help.py index 3a59fd2da2..c59bbc65b4 100644 --- a/snapcraft/cli/help.py +++ b/snapcraft_legacy/cli/help.py @@ -19,14 +19,14 @@ import click -import snapcraft -from snapcraft.internal import errors, sources -from snapcraft.project import errors as project_errors +import snapcraft_legacy +from snapcraft_legacy.internal import errors, sources +from snapcraft_legacy.project import errors as project_errors from . import echo from ._options import get_project -_TOPICS = {"sources": sources, "plugins": snapcraft} +_TOPICS = {"sources": sources, "plugins": snapcraft_legacy} @click.group() @@ -138,7 +138,7 @@ def _module_help(plugin_name: str, devel: bool, base: str): plugin_version = "v1" module = importlib.import_module( - f"snapcraft.plugins.{plugin_version}.{module_name}" + f"snapcraft_legacy.plugins.{plugin_version}.{module_name}" ) if module.__doc__ and devel: help(module) diff --git a/snapcraft_legacy/cli/legacy.py b/snapcraft_legacy/cli/legacy.py new file mode 100644 index 0000000000..9fb533b6f2 --- /dev/null +++ b/snapcraft_legacy/cli/legacy.py @@ -0,0 +1,28 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Legacy execution entry points.""" + +import sys + +from ._runner import run # noqa: F401 + + +def legacy_run(): + run() + + # ensure this call never returns + sys.exit() diff --git a/snapcraft/cli/lifecycle.py b/snapcraft_legacy/cli/lifecycle.py similarity index 98% rename from snapcraft/cli/lifecycle.py rename to snapcraft_legacy/cli/lifecycle.py index e8bfa7e9e8..bf1d1bb512 100644 --- a/snapcraft/cli/lifecycle.py +++ b/snapcraft_legacy/cli/lifecycle.py @@ -27,8 +27,8 @@ import click import progressbar -from snapcraft import file_utils -from snapcraft.internal import ( +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import ( build_providers, deprecations, errors, @@ -37,8 +37,8 @@ project_loader, steps, ) -from snapcraft.internal.repo import ua_manager -from snapcraft.project._sanity_checks import conduct_project_sanity_check +from snapcraft_legacy.internal.repo import ua_manager +from snapcraft_legacy.project._sanity_checks import conduct_project_sanity_check from . import echo from ._errors import TRACEBACK_HOST, TRACEBACK_MANAGED @@ -54,7 +54,7 @@ if typing.TYPE_CHECKING: - from snapcraft.internal.project import Project # noqa: F401 + from snapcraft_legacy.internal.project import Project # noqa: F401 # TODO: when snap is a real step we can simplify the arguments here. diff --git a/snapcraft/cli/remote.py b/snapcraft_legacy/cli/remote.py similarity index 97% rename from snapcraft/cli/remote.py rename to snapcraft_legacy/cli/remote.py index 4132dd4eb1..3b8d56718f 100644 --- a/snapcraft/cli/remote.py +++ b/snapcraft_legacy/cli/remote.py @@ -21,9 +21,9 @@ import click from xdg import BaseDirectory -from snapcraft.formatting_utils import humanize_list -from snapcraft.internal.remote_build import LaunchpadClient, WorkTree, errors -from snapcraft.project import Project +from snapcraft_legacy.formatting_utils import humanize_list +from snapcraft_legacy.internal.remote_build import LaunchpadClient, WorkTree, errors +from snapcraft_legacy.project import Project from . import echo from ._options import PromptOption, get_project diff --git a/snapcraft/cli/snapcraftctl/__init__.py b/snapcraft_legacy/cli/snapcraftctl/__init__.py similarity index 100% rename from snapcraft/cli/snapcraftctl/__init__.py rename to snapcraft_legacy/cli/snapcraftctl/__init__.py diff --git a/snapcraft/cli/snapcraftctl/_runner.py b/snapcraft_legacy/cli/snapcraftctl/_runner.py similarity index 97% rename from snapcraft/cli/snapcraftctl/_runner.py rename to snapcraft_legacy/cli/snapcraftctl/_runner.py index edf27183ed..b0983d492f 100644 --- a/snapcraft/cli/snapcraftctl/_runner.py +++ b/snapcraft_legacy/cli/snapcraftctl/_runner.py @@ -22,8 +22,8 @@ import click -from snapcraft.cli._errors import exception_handler -from snapcraft.internal import errors, log +from snapcraft_legacy.cli._errors import exception_handler +from snapcraft_legacy.internal import errors, log @click.group() diff --git a/snapcraft_legacy/cli/store.py b/snapcraft_legacy/cli/store.py new file mode 100644 index 0000000000..4f454a4f96 --- /dev/null +++ b/snapcraft_legacy/cli/store.py @@ -0,0 +1,373 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2016-2021 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import operator +import os +from datetime import date, timedelta +from textwrap import dedent +from typing import Dict, List, Set, Union + +import click +from tabulate import tabulate + +import snapcraft_legacy +from snapcraft_legacy import storeapi +from snapcraft_legacy._store import StoreClientCLI +from snapcraft_legacy.storeapi import metrics as metrics_module +from . import echo +from ._metrics import convert_metrics_to_table + + +@click.group() +def storecli(): + """Store commands""" + + +def _human_readable_acls(store_client: storeapi.StoreClient) -> str: + acl = store_client.acl() + snap_names = [] + snap_ids = acl["snap_ids"] + + if snap_ids is not None: + try: + for snap_id in snap_ids: + snap_names.append(store_client.get_snap_name_for_id(snap_id)) + except TypeError: + raise RuntimeError(f"invalid snap_ids: {snap_ids!r}") + acl["snap_names"] = snap_names + else: + acl["snap_names"] = None + + human_readable_acl: Dict[str, Union[str, List[str], None]] = { + "expires": str(acl["expires"]) + } + + for key in ("snap_names", "channels", "permissions"): + human_readable_acl[key] = acl[key] + if not acl[key]: + human_readable_acl[key] = "No restriction" + + return dedent( + """\ + snaps: {snap_names} + channels: {channels} + permissions: {permissions} + expires: {expires} + """.format( + **human_readable_acl + ) + ) + + +@storecli.command("upload-metadata") +@click.option( + "--force", + is_flag=True, + help="Force metadata update to override any possible conflict", +) +@click.argument( + "snap-file", + metavar="", + type=click.Path(exists=True, readable=True, resolve_path=True, dir_okay=False), +) +def upload_metadata(snap_file, force): + """Upload metadata from to the store. + + The following information will be retrieved from and used + to update the store: + + \b + - summary + - description + - icon + + If --force is given, it will force the local metadata into the Store, + ignoring any possible conflict. + + \b + Examples: + snapcraft upload-metadata my-snap_0.1_amd64.snap + snapcraft upload-metadata my-snap_0.1_amd64.snap --force + """ + click.echo("Uploading metadata from {!r}".format(os.path.basename(snap_file))) + snapcraft_legacy.upload_metadata(snap_file, force) + + +@storecli.command() +@click.argument("snap-name", metavar="") +@click.option( + "--from-channel", + metavar="", + required=True, + help="The channel to promote from.", +) +@click.option( + "--to-channel", + metavar="", + required=True, + help="The channel to promote to.", +) +@click.option("--yes", is_flag=True, help="Do not prompt for confirmation.") +def promote(snap_name, from_channel, to_channel, yes): + """Promote a build set from to a channel. + + A build set is a set of commonly tagged revisions, the most simple + form of a build set is a set of revisions released to a channel. + + Currently, only channels are supported to release from () + + Prior to releasing, visual confirmation shall be required. + + The format for channels is `[/][/]` where + + \b + - is used to have long term release channels. It is implicitly + set to the default. + - is mandatory and can be either `stable`, `candidate`, `beta` + or `edge`. + - is optional and dynamically creates a channel with a + specific expiration date. + + \b + Examples: + snapcraft promote my-snap --from-channel candidate --to-channel stable + snapcraft promote my-snap --from-channel lts/candidate --to-channel lts/stable + snapcraft promote my-snap --from-channel stable/patch --to-channel stable + snapcraft promote my-snap --from-channel experimental/stable --to-channel stable + """ + echo.warning( + "snapcraft promote does not have a stable CLI interface. Use with caution in scripts." + ) + parsed_from_channel = storeapi.channels.Channel(from_channel) + parsed_to_channel = storeapi.channels.Channel(to_channel) + + if parsed_from_channel == parsed_to_channel: + raise click.BadOptionUsage( + "--to-channel", "--from-channel and --to-channel cannot be the same." + ) + elif ( + parsed_from_channel.risk == "edge" + and parsed_from_channel.branch is None + and yes + ): + raise click.BadOptionUsage( + "--from-channel", + "{!r} is not a valid set value for --from-channel when using --yes.".format( + parsed_from_channel + ), + ) + + store = storeapi.StoreClient() + status_payload = store.get_snap_status(snap_name) + + snap_status = storeapi.status.SnapStatus( + snap_name=snap_name, payload=status_payload + ) + from_channel_set = snap_status.get_channel_set(parsed_from_channel) + echo.info("Build set information for {!r}".format(parsed_from_channel)) + click.echo( + tabulate( + sorted(from_channel_set, key=operator.attrgetter("arch")), + headers=["Arch", "Revision", "Version"], + tablefmt="plain", + ) + ) + if yes or echo.confirm( + "Do you want to promote the current set to the {!r} channel?".format( + parsed_to_channel + ) + ): + for c in from_channel_set: + store.release( + snap_name=snap_name, + revision=str(c.revision), + channels=[str(parsed_to_channel)], + ) + echo.wrapped( + f"Promotion from {parsed_from_channel} to {parsed_to_channel} complete" + ) + else: + echo.wrapped("Channel promotion cancelled") + + +@storecli.command("list-revisions") +@click.option( + "--arch", metavar="", help="The snap architecture to get the status for" +) +@click.argument("snap-name", metavar="") +def list_revisions(snap_name, arch): + """Get the history on the store for . + + This command has an alias of `revisions`. + + \b + Examples: + snapcraft list-revisions my-snap + snapcraft list-revisions my-snap --arch armhf + snapcraft revisions my-snap + """ + releases = StoreClientCLI().get_snap_releases(snap_name=snap_name) + + def get_channels_for_revision(revision: int) -> List[str]: + # channels: the set of channels revision was released to, active or not. + channels: Set[str] = set() + # seen_channel: applies to channels regardless of revision. + # The first channel that shows up for each architecture is to + # be marked as the active channel, all others are historic. + seen_channel: Dict[str, Set[str]] = dict() + + for release in releases.releases: + if release.architecture not in seen_channel: + seen_channel[release.architecture] = set() + + # If the revision is in this release entry and was not seen + # before it means that this channel is active and needs to + # be represented with a *. + if ( + release.revision == revision + and release.channel not in seen_channel[release.architecture] + ): + channels.add(f"{release.channel}*") + # All other releases found for a revision are inactive. + elif ( + release.revision == revision + and release.channel not in channels + and f"{release.channel}*" not in channels + ): + channels.add(release.channel) + + seen_channel[release.architecture].add(release.channel) + + return sorted(list(channels)) + + parsed_revisions = list() + for rev in releases.revisions: + if arch and arch not in rev.architectures: + continue + channels_for_revision = get_channels_for_revision(rev.revision) + if channels_for_revision: + channels = ",".join(channels_for_revision) + else: + channels = "-" + parsed_revisions.append( + ( + rev.revision, + rev.created_at, + ",".join(rev.architectures), + rev.version, + channels, + ) + ) + + tabulated_revisions = tabulate( + parsed_revisions, + numalign="left", + headers=["Rev.", "Uploaded", "Arches", "Version", "Channels"], + tablefmt="plain", + ) + + # 23 revisions + header should not need paging. + if len(parsed_revisions) < 24: + click.echo(tabulated_revisions) + else: + click.echo_via_pager(tabulated_revisions) + + +@storecli.command() +@click.argument("snap-name", metavar="") +@click.argument("track_name", metavar="") +def set_default_track(snap_name: str, track_name: str): + """Set the default track for to . + + The track must be a valid active track for this operation to be successful. + """ + store_client_cli = StoreClientCLI() + + metadata = dict(default_track=track_name) + store_client_cli.upload_metadata(snap_name=snap_name, metadata=metadata, force=True) + + echo.info(f"Default track for {snap_name!r} set to {track_name!r}.") + + +_YESTERDAY = str(date.today() - timedelta(days=1)) + + +@storecli.command() +@click.argument("snap-name", metavar="", required=True) +@click.option( + "--name", + metavar="", + help="Metric name", + type=click.Choice([x.value for x in metrics_module.MetricsNames]), + required=True, +) +@click.option( + "--start", + metavar="", + help="Date in format YYYY-MM-DD", + required=True, + default=_YESTERDAY, +) +@click.option( + "--end", + metavar="", + help="Date in format YYYY-MM-DD", + required=True, + default=_YESTERDAY, +) +@click.option( + "--format", + metavar="", + help="Format for output", + type=click.Choice(["table", "json"]), + required=True, +) +def metrics(snap_name: str, name: str, start: str, end: str, format: str): + """Get metrics for .""" + store = storeapi.StoreClient() + account_info = store.get_account_information() + + try: + snap_id = account_info["snaps"][storeapi.constants.DEFAULT_SERIES][snap_name][ + "snap-id" + ] + except KeyError: + echo.exit_error( + brief="No permissions for snap.", + resolution="Ensure the snap name and credentials are correct.is correct and that the correct credentials are used.", + ) + + mf = metrics_module.MetricsFilter( + snap_id=snap_id, metric_name=name, start=start, end=end + ) + + results = store.get_metrics(filters=[mf], snap_name=snap_name) + + # Sanity check to ensure that only one result is found (as we currently only + # support one query at a time). + if len(results.metrics) != 1: + raise RuntimeError(f"Unexpected metric results from store: {results!r}") + + metric_results = results.metrics[0] + + if format == "json": + output = json.dumps(metric_results.marshal(), indent=2, sort_keys=True) + click.echo(output) + elif format == "table": + rows = convert_metrics_to_table(metric_results, transpose=True) + output = tabulate(rows, tablefmt="plain") + echo.echo_with_pager_if_needed(output) diff --git a/snapcraft/cli/version.py b/snapcraft_legacy/cli/version.py similarity index 90% rename from snapcraft/cli/version.py rename to snapcraft_legacy/cli/version.py index 613aa458e7..054445490d 100644 --- a/snapcraft/cli/version.py +++ b/snapcraft_legacy/cli/version.py @@ -16,9 +16,9 @@ import click -import snapcraft +import snapcraft_legacy -SNAPCRAFT_VERSION_TEMPLATE = "snapcraft, version %(version)s" +SNAPCRAFT_VERSION_TEMPLATE = "snapcraft %(version)s" @click.group() @@ -35,4 +35,4 @@ def version(): snapcraft version snapcraft --version """ - click.echo(SNAPCRAFT_VERSION_TEMPLATE % {"version": snapcraft.__version__}) + click.echo(SNAPCRAFT_VERSION_TEMPLATE % {"version": snapcraft_legacy.__version__}) diff --git a/snapcraft/common.py b/snapcraft_legacy/common.py similarity index 60% rename from snapcraft/common.py rename to snapcraft_legacy/common.py index bdbb49078b..a36ca26006 100644 --- a/snapcraft/common.py +++ b/snapcraft_legacy/common.py @@ -15,13 +15,13 @@ # along with this program. If not, see . # These are now available via file_utils, but don't break API. -from snapcraft.file_utils import link_or_copy # noqa -from snapcraft.file_utils import replace_in_file # noqa +from snapcraft_legacy.file_utils import link_or_copy # noqa +from snapcraft_legacy.file_utils import replace_in_file # noqa # These are now available via formatting_utils, but don't break API. -from snapcraft.formatting_utils import combine_paths # noqa -from snapcraft.formatting_utils import format_path_variable # noqa -from snapcraft.internal.common import get_include_paths # noqa -from snapcraft.internal.common import get_library_paths # noqa -from snapcraft.internal.common import get_python2_path # noqa -from snapcraft.internal.common import isurl # noqa +from snapcraft_legacy.formatting_utils import combine_paths # noqa +from snapcraft_legacy.formatting_utils import format_path_variable # noqa +from snapcraft_legacy.internal.common import get_include_paths # noqa +from snapcraft_legacy.internal.common import get_library_paths # noqa +from snapcraft_legacy.internal.common import get_python2_path # noqa +from snapcraft_legacy.internal.common import isurl # noqa diff --git a/snapcraft/config.py b/snapcraft_legacy/config.py similarity index 98% rename from snapcraft/config.py rename to snapcraft_legacy/config.py index 3e7eac81b5..c33bc1682a 100644 --- a/snapcraft/config.py +++ b/snapcraft_legacy/config.py @@ -22,7 +22,7 @@ from xdg import BaseDirectory -from snapcraft.internal.errors import SnapcraftInvalidCLIConfigError +from snapcraft_legacy.internal.errors import SnapcraftInvalidCLIConfigError logger = logging.getLogger(__name__) diff --git a/snapcraft/extractors/__init__.py b/snapcraft_legacy/extractors/__init__.py similarity index 100% rename from snapcraft/extractors/__init__.py rename to snapcraft_legacy/extractors/__init__.py diff --git a/snapcraft/extractors/_errors.py b/snapcraft_legacy/extractors/_errors.py similarity index 96% rename from snapcraft/extractors/_errors.py rename to snapcraft_legacy/extractors/_errors.py index 3ca24bd20d..9d3ee346bb 100644 --- a/snapcraft/extractors/_errors.py +++ b/snapcraft_legacy/extractors/_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.errors import MetadataExtractionError +from snapcraft_legacy.internal.errors import MetadataExtractionError class UnhandledFileError(MetadataExtractionError): diff --git a/snapcraft/extractors/_metadata.py b/snapcraft_legacy/extractors/_metadata.py similarity index 99% rename from snapcraft/extractors/_metadata.py rename to snapcraft_legacy/extractors/_metadata.py index 3dc7af5962..5678ea8112 100644 --- a/snapcraft/extractors/_metadata.py +++ b/snapcraft_legacy/extractors/_metadata.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional, Set, Union -from snapcraft import yaml_utils +from snapcraft_legacy import yaml_utils class ExtractedMetadata(yaml_utils.SnapcraftYAMLObject): diff --git a/snapcraft/extractors/appstream.py b/snapcraft_legacy/extractors/appstream.py similarity index 99% rename from snapcraft/extractors/appstream.py rename to snapcraft_legacy/extractors/appstream.py index ec76f035ba..cd53eec547 100644 --- a/snapcraft/extractors/appstream.py +++ b/snapcraft_legacy/extractors/appstream.py @@ -23,7 +23,7 @@ import lxml.etree from xdg.DesktopEntry import DesktopEntry -from snapcraft.extractors import _errors +from snapcraft_legacy.extractors import _errors from ._metadata import ExtractedMetadata diff --git a/snapcraft/extractors/setuppy.py b/snapcraft_legacy/extractors/setuppy.py similarity index 98% rename from snapcraft/extractors/setuppy.py rename to snapcraft_legacy/extractors/setuppy.py index 48bcc8ba5d..771e5579a4 100644 --- a/snapcraft/extractors/setuppy.py +++ b/snapcraft_legacy/extractors/setuppy.py @@ -21,7 +21,7 @@ from typing import Dict # noqa: F401 from unittest.mock import patch -from snapcraft.extractors import _errors +from snapcraft_legacy.extractors import _errors from ._metadata import ExtractedMetadata diff --git a/snapcraft/file_utils.py b/snapcraft_legacy/file_utils.py similarity index 99% rename from snapcraft/file_utils.py rename to snapcraft_legacy/file_utils.py index 2b5a8fcef7..a0caca745f 100644 --- a/snapcraft/file_utils.py +++ b/snapcraft_legacy/file_utils.py @@ -27,7 +27,7 @@ from contextlib import contextmanager, suppress from typing import Callable, Generator, List, Optional, Pattern, Set -from snapcraft.internal import common, errors +from snapcraft_legacy.internal import common, errors logger = logging.getLogger(__name__) @@ -396,7 +396,7 @@ def get_linker_version_from_file(linker_file: str) -> str: the linker from libc6 or related. :returns: the version extracted from the linker file. :rtype: string - :raises snapcraft.internal.errors.errors.SnapcraftEnvironmentError: + :raises snapcraft_legacy.internal.errors.errors.SnapcraftEnvironmentError: if linker_file is not of the expected format. """ m = re.search(r"ld-(?P[\d.]+).so$", linker_file) diff --git a/snapcraft/formatting_utils.py b/snapcraft_legacy/formatting_utils.py similarity index 100% rename from snapcraft/formatting_utils.py rename to snapcraft_legacy/formatting_utils.py diff --git a/snapcraft/internal/__init__.py b/snapcraft_legacy/internal/__init__.py similarity index 80% rename from snapcraft/internal/__init__.py rename to snapcraft_legacy/internal/__init__.py index cc8692c426..541746bd07 100644 --- a/snapcraft/internal/__init__.py +++ b/snapcraft_legacy/internal/__init__.py @@ -14,6 +14,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal import cache # noqa -from snapcraft.internal import deltas # noqa -from snapcraft.internal import states # noqa +from snapcraft_legacy.internal import cache # noqa +from snapcraft_legacy.internal import deltas # noqa +from snapcraft_legacy.internal import states # noqa diff --git a/snapcraft/internal/build_providers/__init__.py b/snapcraft_legacy/internal/build_providers/__init__.py similarity index 100% rename from snapcraft/internal/build_providers/__init__.py rename to snapcraft_legacy/internal/build_providers/__init__.py diff --git a/snapcraft/internal/build_providers/_base_provider.py b/snapcraft_legacy/internal/build_providers/_base_provider.py similarity index 98% rename from snapcraft/internal/build_providers/_base_provider.py rename to snapcraft_legacy/internal/build_providers/_base_provider.py index 9ca9717189..fcf707a499 100644 --- a/snapcraft/internal/build_providers/_base_provider.py +++ b/snapcraft_legacy/internal/build_providers/_base_provider.py @@ -30,9 +30,9 @@ import pkg_resources from xdg import BaseDirectory -import snapcraft -from snapcraft import yaml_utils -from snapcraft.internal import common, steps +import snapcraft_legacy +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal import common, steps from . import errors from ._snap import SnapInjector @@ -310,7 +310,7 @@ def _check_environment_needs_cleaning(self) -> bool: ) return True elif pkg_resources.parse_version( - snapcraft._get_version() + snapcraft_legacy._get_version() ) < pkg_resources.parse_version(built_by): self.echoer.warning( f"Build environment was created with newer snapcraft version {built_by!r}, cleaning first." @@ -469,7 +469,7 @@ def _setup_snapcraft(self) -> None: self._save_info( data={ "base": self.project._get_build_base(), - "created-by-snapcraft-version": snapcraft._get_version(), + "created-by-snapcraft-version": snapcraft_legacy._get_version(), "host-project-directory": self.project._project_dir, } ) diff --git a/snapcraft/internal/build_providers/_factory.py b/snapcraft_legacy/internal/build_providers/_factory.py similarity index 100% rename from snapcraft/internal/build_providers/_factory.py rename to snapcraft_legacy/internal/build_providers/_factory.py diff --git a/snapcraft/internal/build_providers/_lxd/__init__.py b/snapcraft_legacy/internal/build_providers/_lxd/__init__.py similarity index 100% rename from snapcraft/internal/build_providers/_lxd/__init__.py rename to snapcraft_legacy/internal/build_providers/_lxd/__init__.py diff --git a/snapcraft/internal/build_providers/_lxd/_images.py b/snapcraft_legacy/internal/build_providers/_lxd/_images.py similarity index 100% rename from snapcraft/internal/build_providers/_lxd/_images.py rename to snapcraft_legacy/internal/build_providers/_lxd/_images.py diff --git a/snapcraft/internal/build_providers/_lxd/_lxd.py b/snapcraft_legacy/internal/build_providers/_lxd/_lxd.py similarity index 99% rename from snapcraft/internal/build_providers/_lxd/_lxd.py rename to snapcraft_legacy/internal/build_providers/_lxd/_lxd.py index acec9199c7..5fa4f898ba 100644 --- a/snapcraft/internal/build_providers/_lxd/_lxd.py +++ b/snapcraft_legacy/internal/build_providers/_lxd/_lxd.py @@ -24,8 +24,8 @@ from time import sleep from typing import Dict, Optional, Sequence -from snapcraft.internal import common, repo -from snapcraft.internal.errors import SnapcraftEnvironmentError +from snapcraft_legacy.internal import common, repo +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError from .._base_provider import Provider, errors from ._images import get_image_source diff --git a/snapcraft/internal/build_providers/_multipass/__init__.py b/snapcraft_legacy/internal/build_providers/_multipass/__init__.py similarity index 100% rename from snapcraft/internal/build_providers/_multipass/__init__.py rename to snapcraft_legacy/internal/build_providers/_multipass/__init__.py diff --git a/snapcraft/internal/build_providers/_multipass/_instance_info.py b/snapcraft_legacy/internal/build_providers/_multipass/_instance_info.py similarity index 95% rename from snapcraft/internal/build_providers/_multipass/_instance_info.py rename to snapcraft_legacy/internal/build_providers/_multipass/_instance_info.py index 6a710b929f..e17f8992b4 100644 --- a/snapcraft/internal/build_providers/_multipass/_instance_info.py +++ b/snapcraft_legacy/internal/build_providers/_multipass/_instance_info.py @@ -17,7 +17,7 @@ import json from typing import Any, Dict, Type -from snapcraft.internal.build_providers import errors +from snapcraft_legacy.internal.build_providers import errors class InstanceInfo: @@ -33,7 +33,7 @@ def from_json( multipass info command. :returns: an InstanceInfo. :rtype: InstanceInfo - :raises snapcraft.internal.build_providers.ProviderInfoDataKeyError: + :raises snapcraft_legacy.internal.build_providers.ProviderInfoDataKeyError: if the instance name cannot be found in the given json or if a required key is missing from that data structure for the instance. """ diff --git a/snapcraft/internal/build_providers/_multipass/_multipass.py b/snapcraft_legacy/internal/build_providers/_multipass/_multipass.py similarity index 99% rename from snapcraft/internal/build_providers/_multipass/_multipass.py rename to snapcraft_legacy/internal/build_providers/_multipass/_multipass.py index 8c34f016fe..d1c3998761 100644 --- a/snapcraft/internal/build_providers/_multipass/_multipass.py +++ b/snapcraft_legacy/internal/build_providers/_multipass/_multipass.py @@ -19,7 +19,7 @@ import sys from typing import Dict, Optional, Sequence -from snapcraft.internal.errors import SnapcraftEnvironmentError +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError from .. import errors from .._base_provider import Provider diff --git a/snapcraft/internal/build_providers/_multipass/_multipass_command.py b/snapcraft_legacy/internal/build_providers/_multipass/_multipass_command.py similarity index 98% rename from snapcraft/internal/build_providers/_multipass/_multipass_command.py rename to snapcraft_legacy/internal/build_providers/_multipass/_multipass_command.py index 0fd1a2e765..5ed4d3beb8 100644 --- a/snapcraft/internal/build_providers/_multipass/_multipass_command.py +++ b/snapcraft_legacy/internal/build_providers/_multipass/_multipass_command.py @@ -30,9 +30,9 @@ Union, ) -from snapcraft.internal import repo -from snapcraft.internal.build_providers import errors -from snapcraft.internal.errors import SnapcraftEnvironmentError +from snapcraft_legacy.internal import repo +from snapcraft_legacy.internal.build_providers import errors +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError from ._windows import windows_install_multipass, windows_reload_multipass_path_env diff --git a/snapcraft/internal/build_providers/_multipass/_windows.py b/snapcraft_legacy/internal/build_providers/_multipass/_windows.py similarity index 97% rename from snapcraft/internal/build_providers/_multipass/_windows.py rename to snapcraft_legacy/internal/build_providers/_multipass/_windows.py index ed86877afd..f6a93fb8a0 100644 --- a/snapcraft/internal/build_providers/_multipass/_windows.py +++ b/snapcraft_legacy/internal/build_providers/_multipass/_windows.py @@ -24,12 +24,12 @@ import requests import simplejson -from snapcraft.file_utils import calculate_sha3_384 -from snapcraft.internal.build_providers.errors import ( +from snapcraft_legacy.file_utils import calculate_sha3_384 +from snapcraft_legacy.internal.build_providers.errors import ( ProviderMultipassDownloadFailed, ProviderMultipassInstallationFailed, ) -from snapcraft.internal.indicators import download_requests_stream +from snapcraft_legacy.internal.indicators import download_requests_stream if sys.platform == "win32": import winreg diff --git a/snapcraft/internal/build_providers/_snap.py b/snapcraft_legacy/internal/build_providers/_snap.py similarity index 98% rename from snapcraft/internal/build_providers/_snap.py rename to snapcraft_legacy/internal/build_providers/_snap.py index fb35c65d0b..a75ded5fe2 100644 --- a/snapcraft/internal/build_providers/_snap.py +++ b/snapcraft_legacy/internal/build_providers/_snap.py @@ -21,8 +21,8 @@ import tempfile from typing import Any, Callable, Dict, List, Optional # noqa: F401 -from snapcraft import storeapi, yaml_utils -from snapcraft.internal import common, repo +from snapcraft_legacy import storeapi, yaml_utils +from snapcraft_legacy.internal import common, repo logger = logging.getLogger(__name__) @@ -198,7 +198,7 @@ def _set_data(self) -> None: install_cmd = ["snap", op.name.lower()] snap_channel = _get_snap_channel(self.snap_name) - store_snap_info = storeapi.StoreClient().snap.get_info(self.snap_name) + store_snap_info = storeapi.SnapAPI().get_info(self.snap_name) snap_channel_map = store_snap_info.get_channel_mapping( risk=snap_channel.risk, track=snap_channel.track ) diff --git a/snapcraft/internal/build_providers/errors.py b/snapcraft_legacy/internal/build_providers/errors.py similarity index 98% rename from snapcraft/internal/build_providers/errors.py rename to snapcraft_legacy/internal/build_providers/errors.py index 6dda2a2da1..18e62d45b4 100644 --- a/snapcraft/internal/build_providers/errors.py +++ b/snapcraft_legacy/internal/build_providers/errors.py @@ -18,8 +18,8 @@ from typing import Sequence # noqa: F401 from typing import Any, Dict, Optional -from snapcraft.internal.errors import SnapcraftError as _SnapcraftError -from snapcraft.internal.errors import SnapcraftException as _SnapcraftException +from snapcraft_legacy.internal.errors import SnapcraftError as _SnapcraftError +from snapcraft_legacy.internal.errors import SnapcraftException as _SnapcraftException class ProviderBaseError(_SnapcraftError): diff --git a/snapcraft/internal/cache/__init__.py b/snapcraft_legacy/internal/cache/__init__.py similarity index 100% rename from snapcraft/internal/cache/__init__.py rename to snapcraft_legacy/internal/cache/__init__.py diff --git a/snapcraft/internal/cache/_apt.py b/snapcraft_legacy/internal/cache/_apt.py similarity index 100% rename from snapcraft/internal/cache/_apt.py rename to snapcraft_legacy/internal/cache/_apt.py diff --git a/snapcraft/internal/cache/_cache.py b/snapcraft_legacy/internal/cache/_cache.py similarity index 100% rename from snapcraft/internal/cache/_cache.py rename to snapcraft_legacy/internal/cache/_cache.py diff --git a/snapcraft/internal/cache/_file.py b/snapcraft_legacy/internal/cache/_file.py similarity index 98% rename from snapcraft/internal/cache/_file.py rename to snapcraft_legacy/internal/cache/_file.py index bf146966c0..20033e3aeb 100644 --- a/snapcraft/internal/cache/_file.py +++ b/snapcraft_legacy/internal/cache/_file.py @@ -18,7 +18,7 @@ import shutil from typing import Optional -from snapcraft.file_utils import calculate_hash +from snapcraft_legacy.file_utils import calculate_hash from ._cache import SnapcraftCache diff --git a/snapcraft/internal/cache/_snap.py b/snapcraft_legacy/internal/cache/_snap.py similarity index 98% rename from snapcraft/internal/cache/_snap.py rename to snapcraft_legacy/internal/cache/_snap.py index 37aacd9b63..436ae968ff 100644 --- a/snapcraft/internal/cache/_snap.py +++ b/snapcraft_legacy/internal/cache/_snap.py @@ -21,7 +21,7 @@ import tempfile from pathlib import Path -from snapcraft import file_utils, yaml_utils +from snapcraft_legacy import file_utils, yaml_utils from ._cache import SnapcraftProjectCache diff --git a/snapcraft/internal/common.py b/snapcraft_legacy/internal/common.py similarity index 99% rename from snapcraft/internal/common.py rename to snapcraft_legacy/internal/common.py index 1c7ccfffaa..923d4b6ff5 100644 --- a/snapcraft/internal/common.py +++ b/snapcraft_legacy/internal/common.py @@ -30,7 +30,7 @@ from pathlib import Path from typing import Callable, List, Union -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors SNAPCRAFT_FILES = ["parts", "stage", "prime"] _DEFAULT_PLUGINDIR = os.path.join(sys.prefix, "share", "snapcraft", "plugins") diff --git a/snapcraft/internal/db/__init__.py b/snapcraft_legacy/internal/db/__init__.py similarity index 100% rename from snapcraft/internal/db/__init__.py rename to snapcraft_legacy/internal/db/__init__.py diff --git a/snapcraft/internal/db/datastore.py b/snapcraft_legacy/internal/db/datastore.py similarity index 97% rename from snapcraft/internal/db/datastore.py rename to snapcraft_legacy/internal/db/datastore.py index e889c47845..719225c382 100644 --- a/snapcraft/internal/db/datastore.py +++ b/snapcraft_legacy/internal/db/datastore.py @@ -21,7 +21,7 @@ import tinydb import yaml -import snapcraft +import snapcraft_legacy from . import errors, migration @@ -83,7 +83,7 @@ def __init__( path: pathlib.Path, migrations: List[Type[migration.Migration]], read_only: bool = False, - snapcraft_version: str = snapcraft.__version__, + snapcraft_version: str = snapcraft_legacy.__version__, ) -> None: self.path = path self._snapcraft_version = snapcraft_version diff --git a/snapcraft/internal/db/errors.py b/snapcraft_legacy/internal/db/errors.py similarity index 95% rename from snapcraft/internal/db/errors.py rename to snapcraft_legacy/internal/db/errors.py index 35b6d5db2c..2f06bf0191 100644 --- a/snapcraft/internal/db/errors.py +++ b/snapcraft_legacy/internal/db/errors.py @@ -16,7 +16,7 @@ import pathlib -from snapcraft.internal.errors import SnapcraftException +from snapcraft_legacy.internal.errors import SnapcraftException class SnapcraftDatastoreVersionUnsupported(SnapcraftException): diff --git a/snapcraft/internal/db/migration.py b/snapcraft_legacy/internal/db/migration.py similarity index 100% rename from snapcraft/internal/db/migration.py rename to snapcraft_legacy/internal/db/migration.py diff --git a/snapcraft/internal/deltas/__init__.py b/snapcraft_legacy/internal/deltas/__init__.py similarity index 100% rename from snapcraft/internal/deltas/__init__.py rename to snapcraft_legacy/internal/deltas/__init__.py diff --git a/snapcraft/internal/deltas/_deltas.py b/snapcraft_legacy/internal/deltas/_deltas.py similarity index 98% rename from snapcraft/internal/deltas/_deltas.py rename to snapcraft_legacy/internal/deltas/_deltas.py index 86d8f03810..ee67f57a43 100644 --- a/snapcraft/internal/deltas/_deltas.py +++ b/snapcraft_legacy/internal/deltas/_deltas.py @@ -21,8 +21,8 @@ import time from typing import BinaryIO, Tuple -from snapcraft import file_utils -from snapcraft.internal.deltas.errors import ( +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal.deltas.errors import ( DeltaFormatOptionError, DeltaGenerationError, DeltaGenerationTooBigError, diff --git a/snapcraft/internal/deltas/_xdelta3.py b/snapcraft_legacy/internal/deltas/_xdelta3.py similarity index 100% rename from snapcraft/internal/deltas/_xdelta3.py rename to snapcraft_legacy/internal/deltas/_xdelta3.py diff --git a/snapcraft/internal/deltas/errors.py b/snapcraft_legacy/internal/deltas/errors.py similarity index 96% rename from snapcraft/internal/deltas/errors.py rename to snapcraft_legacy/internal/deltas/errors.py index 8fe1f00790..a3c406160d 100644 --- a/snapcraft/internal/deltas/errors.py +++ b/snapcraft_legacy/internal/deltas/errors.py @@ -15,7 +15,7 @@ # along with this program. If not, see . -from snapcraft.internal.errors import SnapcraftError +from snapcraft_legacy.internal.errors import SnapcraftError class DeltaGenerationError(SnapcraftError): diff --git a/snapcraft/internal/deprecations.py b/snapcraft_legacy/internal/deprecations.py similarity index 100% rename from snapcraft/internal/deprecations.py rename to snapcraft_legacy/internal/deprecations.py diff --git a/snapcraft/internal/dirs.py b/snapcraft_legacy/internal/dirs.py similarity index 90% rename from snapcraft/internal/dirs.py rename to snapcraft_legacy/internal/dirs.py index 8ca19a8806..de3cb4d79b 100644 --- a/snapcraft/internal/dirs.py +++ b/snapcraft_legacy/internal/dirs.py @@ -18,7 +18,7 @@ import site import sys -import snapcraft.internal.errors +import snapcraft_legacy.internal.errors def _find_windows_data_dir(topdir): @@ -66,22 +66,22 @@ def _find_windows_data_dir(topdir): if os.path.exists(data_dir): return data_dir - raise snapcraft.internal.errors.SnapcraftDataDirectoryMissingError() + raise snapcraft_legacy.internal.errors.SnapcraftDataDirectoryMissingError() def setup_dirs() -> None: """ - Ensure that snapcraft.common plugindir is setup correctly + Ensure that snapcraft_legacy.common plugindir is setup correctly and support running out of a development snapshot """ - from snapcraft.internal import common + from snapcraft_legacy.internal import common topdir = os.path.abspath(os.path.join(__file__, "..", "..", "..")) # Only change the default if we are running from a checkout or from the # snap, or in Windows. if os.path.exists(os.path.join(topdir, "setup.py")): - common.set_plugindir(os.path.join(topdir, "snapcraft", "plugins")) + common.set_plugindir(os.path.join(topdir, "snapcraft_legacy", "plugins")) common.set_schemadir(os.path.join(topdir, "schema")) common.set_extensionsdir(os.path.join(topdir, "extensions")) common.set_keyringsdir(os.path.join(topdir, "keyrings")) @@ -104,7 +104,7 @@ def setup_dirs() -> None: common.set_keyringsdir(os.path.join(parent_dir, "keyrings")) elif sys.platform == "win32": - common.set_plugindir(os.path.join(topdir, "snapcraft", "plugins")) + common.set_plugindir(os.path.join(topdir, "snapcraft_legacy", "plugins")) data_dir = _find_windows_data_dir(topdir) common.set_schemadir(os.path.join(data_dir, "schema")) @@ -120,4 +120,4 @@ def setup_dirs() -> None: common.get_keyringsdir(), ]: if not os.path.exists(d): - raise snapcraft.internal.errors.SnapcraftDataDirectoryMissingError() + raise snapcraft_legacy.internal.errors.SnapcraftDataDirectoryMissingError() diff --git a/snapcraft/internal/elf.py b/snapcraft_legacy/internal/elf.py similarity index 99% rename from snapcraft/internal/elf.py rename to snapcraft_legacy/internal/elf.py index 7ba56c5c10..3d15aeada0 100644 --- a/snapcraft/internal/elf.py +++ b/snapcraft_legacy/internal/elf.py @@ -30,9 +30,9 @@ from elftools.construct import ConstructError from pkg_resources import parse_version -from snapcraft import file_utils -from snapcraft.internal import common, errors, repo -from snapcraft.project._project_options import ProjectOptions +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import common, errors, repo +from snapcraft_legacy.project._project_options import ProjectOptions logger = logging.getLogger(__name__) @@ -565,7 +565,7 @@ def patch(self, *, elf_file: ElfFile) -> None: :param ElfFile elf: a data object representing an elf file and its relevant attributes. - :raises snapcraft.internal.errors.PatcherError: + :raises snapcraft_legacy.internal.errors.PatcherError: raised when the elf_file cannot be patched. """ patchelf_args = [] diff --git a/snapcraft/internal/errors.py b/snapcraft_legacy/internal/errors.py similarity index 98% rename from snapcraft/internal/errors.py rename to snapcraft_legacy/internal/errors.py index c75951ed4e..5f40587d8f 100644 --- a/snapcraft/internal/errors.py +++ b/snapcraft_legacy/internal/errors.py @@ -19,12 +19,12 @@ from subprocess import CalledProcessError from typing import TYPE_CHECKING, Dict, List, Optional, Union -from snapcraft import formatting_utils -from snapcraft.internal import steps +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import steps if TYPE_CHECKING: - from snapcraft.internal.pluginhandler._dirty_report import DirtyReport - from snapcraft.internal.pluginhandler._outdated_report import OutdatedReport + from snapcraft_legacy.internal.pluginhandler._dirty_report import DirtyReport + from snapcraft_legacy.internal.pluginhandler._outdated_report import OutdatedReport # Commonly used resolution message to clean and retry build. diff --git a/snapcraft/internal/indicators.py b/snapcraft_legacy/internal/indicators.py similarity index 100% rename from snapcraft/internal/indicators.py rename to snapcraft_legacy/internal/indicators.py diff --git a/snapcraft/internal/lifecycle/__init__.py b/snapcraft_legacy/internal/lifecycle/__init__.py similarity index 100% rename from snapcraft/internal/lifecycle/__init__.py rename to snapcraft_legacy/internal/lifecycle/__init__.py diff --git a/snapcraft/internal/lifecycle/_clean.py b/snapcraft_legacy/internal/lifecycle/_clean.py similarity index 97% rename from snapcraft/internal/lifecycle/_clean.py rename to snapcraft_legacy/internal/lifecycle/_clean.py index 9c5ad7dfbb..a6cb4fba93 100644 --- a/snapcraft/internal/lifecycle/_clean.py +++ b/snapcraft_legacy/internal/lifecycle/_clean.py @@ -19,14 +19,14 @@ import shutil from typing import TYPE_CHECKING, Optional -from snapcraft import formatting_utils -from snapcraft.internal import errors, mountinfo, project_loader, steps +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import errors, mountinfo, project_loader, steps logger = logging.getLogger(__name__) if TYPE_CHECKING: - from snapcraft.project import Project + from snapcraft_legacy.project import Project def _clean_part(part_name, step, config, staged_state, primed_state): diff --git a/snapcraft/internal/lifecycle/_init.py b/snapcraft_legacy/internal/lifecycle/_init.py similarity index 98% rename from snapcraft/internal/lifecycle/_init.py rename to snapcraft_legacy/internal/lifecycle/_init.py index c1c8b8187d..d0f5e22c61 100644 --- a/snapcraft/internal/lifecycle/_init.py +++ b/snapcraft_legacy/internal/lifecycle/_init.py @@ -17,7 +17,7 @@ import os from textwrap import dedent -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors _TEMPLATE_YAML = dedent( """\ diff --git a/snapcraft/internal/lifecycle/_runner.py b/snapcraft_legacy/internal/lifecycle/_runner.py similarity index 98% rename from snapcraft/internal/lifecycle/_runner.py rename to snapcraft_legacy/internal/lifecycle/_runner.py index a65a30d578..f7ac3f7de5 100644 --- a/snapcraft/internal/lifecycle/_runner.py +++ b/snapcraft_legacy/internal/lifecycle/_runner.py @@ -17,8 +17,8 @@ import logging from typing import List, Optional, Sequence, Set -from snapcraft import config, plugins, storeapi -from snapcraft.internal import ( +from snapcraft_legacy import config, plugins, storeapi +from snapcraft_legacy.internal import ( common, errors, pluginhandler, @@ -27,8 +27,8 @@ states, steps, ) -from snapcraft.internal.meta._snap_packaging import create_snap_packaging -from snapcraft.internal.pluginhandler._part_environment import ( +from snapcraft_legacy.internal.meta._snap_packaging import create_snap_packaging +from snapcraft_legacy.internal.pluginhandler._part_environment import ( get_snapcraft_part_directory_environment, ) @@ -46,7 +46,7 @@ def _get_required_grade(*, base: Optional[str], arch: str) -> str: # We use storeapi instead of repo.snaps so this can work under Docker # and related environments. try: - base_info = storeapi.StoreClient().snap.get_info(base) + base_info = storeapi.SnapAPI().get_info(base) base_info.get_channel_mapping(risk="stable", arch=arch) except storeapi.errors.SnapNotFoundError: return "devel" diff --git a/snapcraft/internal/lifecycle/_status_cache.py b/snapcraft_legacy/internal/lifecycle/_status_cache.py similarity index 98% rename from snapcraft/internal/lifecycle/_status_cache.py rename to snapcraft_legacy/internal/lifecycle/_status_cache.py index 622497803a..ceb1ce132a 100644 --- a/snapcraft/internal/lifecycle/_status_cache.py +++ b/snapcraft_legacy/internal/lifecycle/_status_cache.py @@ -18,8 +18,8 @@ import contextlib from typing import Any, Dict, List, Optional, Set -import snapcraft.internal.project_loader._config as _config -from snapcraft.internal import errors, pluginhandler, steps +import snapcraft_legacy.internal.project_loader._config as _config +from snapcraft_legacy.internal import errors, pluginhandler, steps _DirtyReport = Dict[str, Dict[steps.Step, Optional[pluginhandler.DirtyReport]]] _OutdatedReport = Dict[str, Dict[steps.Step, Optional[pluginhandler.OutdatedReport]]] diff --git a/snapcraft/internal/lifecycle/errors.py b/snapcraft_legacy/internal/lifecycle/errors.py similarity index 91% rename from snapcraft/internal/lifecycle/errors.py rename to snapcraft_legacy/internal/lifecycle/errors.py index a4db40fb56..fde3ed3002 100644 --- a/snapcraft/internal/lifecycle/errors.py +++ b/snapcraft_legacy/internal/lifecycle/errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.errors import SnapcraftError as _SnapcraftError +from snapcraft_legacy.internal.errors import SnapcraftError as _SnapcraftError class PackVerificationError(_SnapcraftError): diff --git a/snapcraft/internal/log.py b/snapcraft_legacy/internal/log.py similarity index 97% rename from snapcraft/internal/log.py rename to snapcraft_legacy/internal/log.py index b2e442becb..ae6f4b94eb 100644 --- a/snapcraft/internal/log.py +++ b/snapcraft_legacy/internal/log.py @@ -18,7 +18,7 @@ import logging import sys -from snapcraft.internal.indicators import is_dumb_terminal +from snapcraft_legacy.internal.indicators import is_dumb_terminal class _StdoutFilter(logging.Filter): diff --git a/snapcraft/internal/lxd/__init__.py b/snapcraft_legacy/internal/lxd/__init__.py similarity index 100% rename from snapcraft/internal/lxd/__init__.py rename to snapcraft_legacy/internal/lxd/__init__.py diff --git a/snapcraft/internal/mangling.py b/snapcraft_legacy/internal/mangling.py similarity index 97% rename from snapcraft/internal/mangling.py rename to snapcraft_legacy/internal/mangling.py index 0fbb0125d8..b1f9ad732b 100644 --- a/snapcraft/internal/mangling.py +++ b/snapcraft_legacy/internal/mangling.py @@ -18,8 +18,8 @@ import subprocess from typing import FrozenSet -from snapcraft import file_utils -from snapcraft.internal import elf +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import elf logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/meta/__init__.py b/snapcraft_legacy/internal/meta/__init__.py similarity index 100% rename from snapcraft/internal/meta/__init__.py rename to snapcraft_legacy/internal/meta/__init__.py diff --git a/snapcraft/internal/meta/_manifest.py b/snapcraft_legacy/internal/meta/_manifest.py similarity index 91% rename from snapcraft/internal/meta/_manifest.py rename to snapcraft_legacy/internal/meta/_manifest.py index 105ecfef20..5e5ae9cd3d 100644 --- a/snapcraft/internal/meta/_manifest.py +++ b/snapcraft_legacy/internal/meta/_manifest.py @@ -20,17 +20,17 @@ from collections import OrderedDict from typing import TYPE_CHECKING, Any, Dict, Set -import snapcraft -from snapcraft.internal import errors, os_release, steps -from snapcraft.internal.states import GlobalState, get_state +import snapcraft_legacy +from snapcraft_legacy.internal import errors, os_release, steps +from snapcraft_legacy.internal.states import GlobalState, get_state if TYPE_CHECKING: - from snapcraft.project import Project + from snapcraft_legacy.project import Project def annotate_snapcraft(project: "Project", data: Dict[str, Any]) -> Dict[str, Any]: manifest = OrderedDict() # type: Dict[str, Any] - manifest["snapcraft-version"] = snapcraft._get_version() + manifest["snapcraft-version"] = snapcraft_legacy._get_version() manifest["snapcraft-started-at"] = project._get_start_time().isoformat() + "Z" release = os_release.OsRelease() diff --git a/snapcraft/internal/meta/_snap_packaging.py b/snapcraft_legacy/internal/meta/_snap_packaging.py similarity index 97% rename from snapcraft/internal/meta/_snap_packaging.py rename to snapcraft_legacy/internal/meta/_snap_packaging.py index f355d42e96..b9af0efb09 100644 --- a/snapcraft/internal/meta/_snap_packaging.py +++ b/snapcraft_legacy/internal/meta/_snap_packaging.py @@ -29,16 +29,22 @@ import requests -from snapcraft import extractors, file_utils, formatting_utils, shell_utils, yaml_utils -from snapcraft.extractors import _metadata -from snapcraft.internal import common, errors, project_loader, states -from snapcraft.internal.deprecations import handle_deprecation_notice -from snapcraft.internal.meta import _manifest, _version -from snapcraft.internal.meta import errors as meta_errors -from snapcraft.internal.meta.application import ApplicationAdapter -from snapcraft.internal.meta.snap import Snap -from snapcraft.internal.project_loader import _config -from snapcraft.project import _schema +from snapcraft_legacy import ( + extractors, + file_utils, + formatting_utils, + shell_utils, + yaml_utils, +) +from snapcraft_legacy.extractors import _metadata +from snapcraft_legacy.internal import common, errors, project_loader, states +from snapcraft_legacy.internal.deprecations import handle_deprecation_notice +from snapcraft_legacy.internal.meta import _manifest, _version +from snapcraft_legacy.internal.meta import errors as meta_errors +from snapcraft_legacy.internal.meta.application import ApplicationAdapter +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.internal.project_loader import _config +from snapcraft_legacy.project import _schema logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/meta/_utils.py b/snapcraft_legacy/internal/meta/_utils.py similarity index 100% rename from snapcraft/internal/meta/_utils.py rename to snapcraft_legacy/internal/meta/_utils.py diff --git a/snapcraft/internal/meta/_version.py b/snapcraft_legacy/internal/meta/_version.py similarity index 95% rename from snapcraft/internal/meta/_version.py rename to snapcraft_legacy/internal/meta/_version.py index 79312c69c6..77a56d6754 100644 --- a/snapcraft/internal/meta/_version.py +++ b/snapcraft_legacy/internal/meta/_version.py @@ -17,8 +17,8 @@ import logging import subprocess -from snapcraft import shell_utils -from snapcraft.internal import sources +from snapcraft_legacy import shell_utils +from snapcraft_legacy.internal import sources from . import errors diff --git a/snapcraft/internal/meta/application.py b/snapcraft_legacy/internal/meta/application.py similarity index 99% rename from snapcraft/internal/meta/application.py rename to snapcraft_legacy/internal/meta/application.py index 079fc33407..2915828c51 100644 --- a/snapcraft/internal/meta/application.py +++ b/snapcraft_legacy/internal/meta/application.py @@ -19,7 +19,7 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Sequence # noqa: F401 -from snapcraft import yaml_utils +from snapcraft_legacy import yaml_utils from . import errors from ._utils import _executable_is_valid diff --git a/snapcraft/internal/meta/command.py b/snapcraft_legacy/internal/meta/command.py similarity index 99% rename from snapcraft/internal/meta/command.py rename to snapcraft_legacy/internal/meta/command.py index 6a020854c4..9317e20457 100644 --- a/snapcraft/internal/meta/command.py +++ b/snapcraft_legacy/internal/meta/command.py @@ -22,7 +22,7 @@ import shutil from typing import Optional -from snapcraft.internal import common +from snapcraft_legacy.internal import common from . import errors from ._utils import _executable_is_valid diff --git a/snapcraft/internal/meta/desktop.py b/snapcraft_legacy/internal/meta/desktop.py similarity index 100% rename from snapcraft/internal/meta/desktop.py rename to snapcraft_legacy/internal/meta/desktop.py diff --git a/snapcraft/internal/meta/errors.py b/snapcraft_legacy/internal/meta/errors.py similarity index 98% rename from snapcraft/internal/meta/errors.py rename to snapcraft_legacy/internal/meta/errors.py index 0b8d4a05f8..a98565ee58 100644 --- a/snapcraft/internal/meta/errors.py +++ b/snapcraft_legacy/internal/meta/errors.py @@ -16,8 +16,8 @@ from typing import List, Optional -from snapcraft import formatting_utils -from snapcraft.internal import errors +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import errors class CommandError(errors.SnapcraftError): diff --git a/snapcraft/internal/meta/hooks.py b/snapcraft_legacy/internal/meta/hooks.py similarity index 98% rename from snapcraft/internal/meta/hooks.py rename to snapcraft_legacy/internal/meta/hooks.py index d02637a824..86f03d081d 100644 --- a/snapcraft/internal/meta/hooks.py +++ b/snapcraft_legacy/internal/meta/hooks.py @@ -18,7 +18,7 @@ from collections import OrderedDict from typing import Any, Dict, List, Optional -from snapcraft.internal.meta.errors import HookValidationError +from snapcraft_legacy.internal.meta.errors import HookValidationError class Hook: diff --git a/snapcraft/internal/meta/package_repository.py b/snapcraft_legacy/internal/meta/package_repository.py similarity index 100% rename from snapcraft/internal/meta/package_repository.py rename to snapcraft_legacy/internal/meta/package_repository.py diff --git a/snapcraft/internal/meta/plugs.py b/snapcraft_legacy/internal/meta/plugs.py similarity index 98% rename from snapcraft/internal/meta/plugs.py rename to snapcraft_legacy/internal/meta/plugs.py index 68b87e14dd..9f716b9b1c 100644 --- a/snapcraft/internal/meta/plugs.py +++ b/snapcraft_legacy/internal/meta/plugs.py @@ -19,7 +19,7 @@ from copy import deepcopy from typing import Any, Dict, Optional, Type -from snapcraft.internal.meta.errors import PlugValidationError +from snapcraft_legacy.internal.meta.errors import PlugValidationError logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/meta/slots.py b/snapcraft_legacy/internal/meta/slots.py similarity index 99% rename from snapcraft/internal/meta/slots.py rename to snapcraft_legacy/internal/meta/slots.py index 4703296d9d..9e3d417840 100644 --- a/snapcraft/internal/meta/slots.py +++ b/snapcraft_legacy/internal/meta/slots.py @@ -21,7 +21,7 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Set, Tuple, Type -from snapcraft.internal.meta.errors import SlotValidationError +from snapcraft_legacy.internal.meta.errors import SlotValidationError logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/meta/snap.py b/snapcraft_legacy/internal/meta/snap.py similarity index 97% rename from snapcraft/internal/meta/snap.py rename to snapcraft_legacy/internal/meta/snap.py index 1be2cbc7ab..b355d10153 100644 --- a/snapcraft/internal/meta/snap.py +++ b/snapcraft_legacy/internal/meta/snap.py @@ -20,15 +20,15 @@ from copy import deepcopy from typing import Any, Dict, List, Optional, Sequence, Set -from snapcraft import yaml_utils -from snapcraft.internal import common -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.application import Application -from snapcraft.internal.meta.hooks import Hook -from snapcraft.internal.meta.package_repository import PackageRepository -from snapcraft.internal.meta.plugs import ContentPlug, Plug -from snapcraft.internal.meta.slots import ContentSlot, Slot -from snapcraft.internal.meta.system_user import SystemUser +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal import common +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.application import Application +from snapcraft_legacy.internal.meta.hooks import Hook +from snapcraft_legacy.internal.meta.package_repository import PackageRepository +from snapcraft_legacy.internal.meta.plugs import ContentPlug, Plug +from snapcraft_legacy.internal.meta.slots import ContentSlot, Slot +from snapcraft_legacy.internal.meta.system_user import SystemUser logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/meta/system_user.py b/snapcraft_legacy/internal/meta/system_user.py similarity index 98% rename from snapcraft/internal/meta/system_user.py rename to snapcraft_legacy/internal/meta/system_user.py index b06f0b693d..82cb67640d 100644 --- a/snapcraft/internal/meta/system_user.py +++ b/snapcraft_legacy/internal/meta/system_user.py @@ -19,7 +19,7 @@ from collections import OrderedDict from typing import Any, Dict -from snapcraft.internal.meta import errors +from snapcraft_legacy.internal.meta import errors logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/mountinfo.py b/snapcraft_legacy/internal/mountinfo.py similarity index 98% rename from snapcraft/internal/mountinfo.py rename to snapcraft_legacy/internal/mountinfo.py index 260215bf8a..5aea923ef7 100644 --- a/snapcraft/internal/mountinfo.py +++ b/snapcraft_legacy/internal/mountinfo.py @@ -20,7 +20,7 @@ import logging from typing import Dict, List # noqa: F401 -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/os_release.py b/snapcraft_legacy/internal/os_release.py similarity index 98% rename from snapcraft/internal/os_release.py rename to snapcraft_legacy/internal/os_release.py index 778a091af2..6a77728df0 100644 --- a/snapcraft/internal/os_release.py +++ b/snapcraft_legacy/internal/os_release.py @@ -20,7 +20,7 @@ # doesn't like that very much, so noqa. from typing import Dict # noqa -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors _ID_TO_UBUNTU_CODENAME = { "17.10": "artful", diff --git a/snapcraft/internal/pluginhandler/__init__.py b/snapcraft_legacy/internal/pluginhandler/__init__.py similarity index 98% rename from snapcraft/internal/pluginhandler/__init__.py rename to snapcraft_legacy/internal/pluginhandler/__init__.py index af4a0fbf6f..ba9d6fe24f 100644 --- a/snapcraft/internal/pluginhandler/__init__.py +++ b/snapcraft_legacy/internal/pluginhandler/__init__.py @@ -28,10 +28,19 @@ from glob import iglob from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set, cast -import snapcraft.extractors -from snapcraft import file_utils, plugins, yaml_utils -from snapcraft.internal import common, elf, errors, repo, sources, states, steps, xattrs -from snapcraft.internal.mangling import clear_execstack +import snapcraft_legacy.extractors +from snapcraft_legacy import file_utils, plugins, yaml_utils +from snapcraft_legacy.internal import ( + common, + elf, + errors, + repo, + sources, + states, + steps, + xattrs, +) +from snapcraft_legacy.internal.mangling import clear_execstack from ._build_attributes import BuildAttributes from ._dependencies import MissingDependencyResolver @@ -44,7 +53,7 @@ from ._runner import Runner if TYPE_CHECKING: - from snapcraft.project import Project + from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) @@ -122,8 +131,8 @@ def __init__( # Scriptlet data is a dict of dicts for each step self._scriptlet_metadata: Dict[ - steps.Step, snapcraft.extractors.ExtractedMetadata - ] = collections.defaultdict(snapcraft.extractors.ExtractedMetadata) + steps.Step, snapcraft_legacy.extractors.ExtractedMetadata + ] = collections.defaultdict(snapcraft_legacy.extractors.ExtractedMetadata) if isinstance(plugin, plugins.v2.PluginV2): self._shell = "/bin/bash" @@ -214,7 +223,7 @@ def _get_source_handler(self, properties): def _set_version(self, *, version): try: self._set_scriptlet_metadata( - snapcraft.extractors.ExtractedMetadata(version=version) + snapcraft_legacy.extractors.ExtractedMetadata(version=version) ) except errors.ScriptletDuplicateDataError as e: raise errors.ScriptletDuplicateFieldError("version", e.other_step) @@ -222,13 +231,13 @@ def _set_version(self, *, version): def _set_grade(self, *, grade): try: self._set_scriptlet_metadata( - snapcraft.extractors.ExtractedMetadata(grade=grade) + snapcraft_legacy.extractors.ExtractedMetadata(grade=grade) ) except errors.ScriptletDuplicateDataError as e: raise errors.ScriptletDuplicateFieldError("grade", e.other_step) def _check_scriplet_metadata_dupe( - self, metadata: snapcraft.extractors.ExtractedMetadata, step: steps.Step + self, metadata: snapcraft_legacy.extractors.ExtractedMetadata, step: steps.Step ): # First, ensure the metadata set here doesn't conflict with metadata # already set for this step @@ -249,7 +258,9 @@ def _check_scriplet_metadata_dupe( step, other_step, list(conflicts) ) - def _set_scriptlet_metadata(self, metadata: snapcraft.extractors.ExtractedMetadata): + def _set_scriptlet_metadata( + self, metadata: snapcraft_legacy.extractors.ExtractedMetadata + ): try: step = self.next_step() self._check_scriplet_metadata_dupe(metadata, step) @@ -527,7 +538,7 @@ def mark_pull_done(self): part_build_snaps = self._grammar_processor.get_build_snaps() # Extract any requested metadata available in the source directory - metadata = snapcraft.extractors.ExtractedMetadata() + metadata = snapcraft_legacy.extractors.ExtractedMetadata() metadata_files = [] for parse_relpath in self._part_properties.get("parse-info", []): with contextlib.suppress(errors.MissingMetadataFileError): @@ -723,7 +734,7 @@ def mark_build_done(self): # Extract any requested metadata available in the build directory, # followed by the install directory (which takes precedence) metadata_files = [] - metadata = snapcraft.extractors.ExtractedMetadata() + metadata = snapcraft_legacy.extractors.ExtractedMetadata() for parse_relpath in self._part_properties.get("parse-info", []): found_path = None with contextlib.suppress(errors.MissingMetadataFileError): @@ -1267,7 +1278,7 @@ def _migrate_files( src = os.path.join(srcdir, snap_dir) dst = os.path.join(dstdir, snap_dir) - snapcraft.file_utils.create_similar_directory(src, dst) + snapcraft_legacy.file_utils.create_similar_directory(src, dst) for snap_file in sorted(snap_files): src = os.path.join(srcdir, snap_file) diff --git a/snapcraft/internal/pluginhandler/_build_attributes.py b/snapcraft_legacy/internal/pluginhandler/_build_attributes.py similarity index 100% rename from snapcraft/internal/pluginhandler/_build_attributes.py rename to snapcraft_legacy/internal/pluginhandler/_build_attributes.py diff --git a/snapcraft/internal/pluginhandler/_dependencies.py b/snapcraft_legacy/internal/pluginhandler/_dependencies.py similarity index 98% rename from snapcraft/internal/pluginhandler/_dependencies.py rename to snapcraft_legacy/internal/pluginhandler/_dependencies.py index 6cc63a58d7..aed4a28cf6 100644 --- a/snapcraft/internal/pluginhandler/_dependencies.py +++ b/snapcraft_legacy/internal/pluginhandler/_dependencies.py @@ -16,7 +16,7 @@ from typing import Sequence, Set -from snapcraft.internal import repo +from snapcraft_legacy.internal import repo _MSG_EXTEND_STAGE_PACKAGES = ( "The {part_name!r} part is missing libraries that are not " diff --git a/snapcraft/internal/pluginhandler/_dirty_report.py b/snapcraft_legacy/internal/pluginhandler/_dirty_report.py similarity index 99% rename from snapcraft/internal/pluginhandler/_dirty_report.py rename to snapcraft_legacy/internal/pluginhandler/_dirty_report.py index 7dde96d5c6..a708d2b249 100644 --- a/snapcraft/internal/pluginhandler/_dirty_report.py +++ b/snapcraft_legacy/internal/pluginhandler/_dirty_report.py @@ -16,7 +16,7 @@ from typing import List, Set, Union -from snapcraft import formatting_utils +from snapcraft_legacy import formatting_utils # Ideally we'd just use Collection from typing, but that wasn't introduced # until 3.6 diff --git a/snapcraft/internal/pluginhandler/_metadata_extraction.py b/snapcraft_legacy/internal/pluginhandler/_metadata_extraction.py similarity index 93% rename from snapcraft/internal/pluginhandler/_metadata_extraction.py rename to snapcraft_legacy/internal/pluginhandler/_metadata_extraction.py index 4a781e82d8..803655e6f5 100644 --- a/snapcraft/internal/pluginhandler/_metadata_extraction.py +++ b/snapcraft_legacy/internal/pluginhandler/_metadata_extraction.py @@ -19,8 +19,8 @@ import os import pkgutil -from snapcraft import extractors -from snapcraft.internal.errors import ( +from snapcraft_legacy import extractors +from snapcraft_legacy.internal.errors import ( InvalidExtractorValueError, MissingMetadataFileError, UnhandledMetadataFileTypeError, @@ -41,7 +41,7 @@ def extract_metadata( # We only care about non-private modules in here if not module_name.startswith("_"): module = importlib.import_module( - "snapcraft.extractors.{}".format(module_name) + "snapcraft_legacy.extractors.{}".format(module_name) ) try: diff --git a/snapcraft/internal/pluginhandler/_outdated_report.py b/snapcraft_legacy/internal/pluginhandler/_outdated_report.py similarity index 96% rename from snapcraft/internal/pluginhandler/_outdated_report.py rename to snapcraft_legacy/internal/pluginhandler/_outdated_report.py index 0021559fe9..79ef285141 100644 --- a/snapcraft/internal/pluginhandler/_outdated_report.py +++ b/snapcraft_legacy/internal/pluginhandler/_outdated_report.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft import formatting_utils -from snapcraft.internal import steps +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import steps class OutdatedReport: diff --git a/snapcraft/internal/pluginhandler/_part_environment.py b/snapcraft_legacy/internal/pluginhandler/_part_environment.py similarity index 97% rename from snapcraft/internal/pluginhandler/_part_environment.py rename to snapcraft_legacy/internal/pluginhandler/_part_environment.py index 817df08099..1c5823605c 100644 --- a/snapcraft/internal/pluginhandler/_part_environment.py +++ b/snapcraft_legacy/internal/pluginhandler/_part_environment.py @@ -16,11 +16,11 @@ from typing import TYPE_CHECKING, Dict, Optional -from snapcraft import formatting_utils -from snapcraft.internal import common, steps +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import common, steps if TYPE_CHECKING: - from snapcraft.project import Project + from snapcraft_legacy.project import Project from . import PluginHandler diff --git a/snapcraft/internal/pluginhandler/_patchelf.py b/snapcraft_legacy/internal/pluginhandler/_patchelf.py similarity index 98% rename from snapcraft/internal/pluginhandler/_patchelf.py rename to snapcraft_legacy/internal/pluginhandler/_patchelf.py index a57fa02ba7..68291116ef 100644 --- a/snapcraft/internal/pluginhandler/_patchelf.py +++ b/snapcraft_legacy/internal/pluginhandler/_patchelf.py @@ -19,8 +19,8 @@ from typing import Dict # noqa: F401 from typing import FrozenSet, List -from snapcraft.internal import elf, errors -from snapcraft.project import Project +from snapcraft_legacy.internal import elf, errors +from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/pluginhandler/_plugin_loader.py b/snapcraft_legacy/internal/pluginhandler/_plugin_loader.py similarity index 95% rename from snapcraft/internal/pluginhandler/_plugin_loader.py rename to snapcraft_legacy/internal/pluginhandler/_plugin_loader.py index 89601c884f..2e60805844 100644 --- a/snapcraft/internal/pluginhandler/_plugin_loader.py +++ b/snapcraft_legacy/internal/pluginhandler/_plugin_loader.py @@ -22,10 +22,10 @@ import jsonschema -import snapcraft.yaml_utils.errors -from snapcraft import plugins -from snapcraft.internal import errors -from snapcraft.project import Project +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy import plugins +from snapcraft_legacy.internal import errors +from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) @@ -135,7 +135,7 @@ def _get_local_plugin_class(*, plugin_name: str, local_plugins_dir: str): logger.debug( f"Plugin attribute {attr!r} has __module__: {attr.__module__!r}" ) - if attr.__module__.startswith("snapcraft.plugins"): + if attr.__module__.startswith("snapcraft_legacy.plugins"): continue return attr else: @@ -190,7 +190,9 @@ def _make_options( try: jsonschema.validate(properties, plugin_schema) except jsonschema.ValidationError as e: - error = snapcraft.yaml_utils.errors.YamlValidationError.from_validation_error(e) + error = snapcraft_legacy.yaml_utils.errors.YamlValidationError.from_validation_error( + e + ) raise errors.PluginError( "properties failed to load for {}: {}".format(part_name, error.message) ) diff --git a/snapcraft/internal/pluginhandler/_runner.py b/snapcraft_legacy/internal/pluginhandler/_runner.py similarity index 99% rename from snapcraft/internal/pluginhandler/_runner.py rename to snapcraft_legacy/internal/pluginhandler/_runner.py index 10c2a57486..54e7423031 100644 --- a/snapcraft/internal/pluginhandler/_runner.py +++ b/snapcraft_legacy/internal/pluginhandler/_runner.py @@ -24,7 +24,7 @@ import time from typing import Any, Callable, Dict -from snapcraft.internal import common, errors, steps +from snapcraft_legacy.internal import common, errors, steps class Runner: diff --git a/snapcraft/internal/project_loader/__init__.py b/snapcraft_legacy/internal/project_loader/__init__.py similarity index 96% rename from snapcraft/internal/project_loader/__init__.py rename to snapcraft_legacy/internal/project_loader/__init__.py index 860f35b5f9..f0b42413a0 100644 --- a/snapcraft/internal/project_loader/__init__.py +++ b/snapcraft_legacy/internal/project_loader/__init__.py @@ -25,7 +25,7 @@ from ._parts_config import PartsConfig # noqa: F401 if TYPE_CHECKING: - from snapcraft.project import Project # noqa: F401 + from snapcraft_legacy.project import Project # noqa: F401 def load_config(project: "Project"): diff --git a/snapcraft/internal/project_loader/_config.py b/snapcraft_legacy/internal/project_loader/_config.py similarity index 96% rename from snapcraft/internal/project_loader/_config.py rename to snapcraft_legacy/internal/project_loader/_config.py index 2c39e90381..2f2253eb31 100644 --- a/snapcraft/internal/project_loader/_config.py +++ b/snapcraft_legacy/internal/project_loader/_config.py @@ -24,15 +24,15 @@ import jsonschema -from snapcraft import formatting_utils, plugins, project -from snapcraft.internal import deprecations, repo, states, steps -from snapcraft.internal.meta.package_repository import PackageRepository -from snapcraft.internal.meta.snap import Snap -from snapcraft.internal.pluginhandler._part_environment import ( +from snapcraft_legacy import formatting_utils, plugins, project +from snapcraft_legacy.internal import deprecations, repo, states, steps +from snapcraft_legacy.internal.meta.package_repository import PackageRepository +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.internal.pluginhandler._part_environment import ( get_snapcraft_global_environment, ) -from snapcraft.internal.repo import apt_key_manager, apt_sources_manager -from snapcraft.project._schema import Validator +from snapcraft_legacy.internal.repo import apt_key_manager, apt_sources_manager +from snapcraft_legacy.project._schema import Validator from . import errors, grammar_processing, replace_attr from ._env import environment_to_replacements, runtime_env @@ -213,7 +213,7 @@ def __init__(self, project: project.Project) -> None: self._ensure_no_duplicate_app_aliases() self._global_grammar_processor = grammar_processing.GlobalGrammarProcessor( - properties=self.data, project=project + properties=self.data, arch=project.deb_arch, target_arch=project.target_arch ) # XXX: Resetting snap_meta due to above mangling of data. diff --git a/snapcraft/internal/project_loader/_env.py b/snapcraft_legacy/internal/project_loader/_env.py similarity index 96% rename from snapcraft/internal/project_loader/_env.py rename to snapcraft_legacy/internal/project_loader/_env.py index dd75b616f2..e3f8df3f85 100644 --- a/snapcraft/internal/project_loader/_env.py +++ b/snapcraft_legacy/internal/project_loader/_env.py @@ -16,8 +16,8 @@ from typing import Dict, List -from snapcraft import formatting_utils -from snapcraft.internal import common, elf +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import common, elf def runtime_env(root: str, arch_triplet: str) -> List[str]: diff --git a/snapcraft/internal/project_loader/_extensions/__init__.py b/snapcraft_legacy/internal/project_loader/_extensions/__init__.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/__init__.py rename to snapcraft_legacy/internal/project_loader/_extensions/__init__.py diff --git a/snapcraft/internal/project_loader/_extensions/_extension.py b/snapcraft_legacy/internal/project_loader/_extensions/_extension.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/_extension.py rename to snapcraft_legacy/internal/project_loader/_extensions/_extension.py diff --git a/snapcraft/internal/project_loader/_extensions/_flutter_meta.py b/snapcraft_legacy/internal/project_loader/_extensions/_flutter_meta.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/_flutter_meta.py rename to snapcraft_legacy/internal/project_loader/_extensions/_flutter_meta.py diff --git a/snapcraft/internal/project_loader/_extensions/_utils.py b/snapcraft_legacy/internal/project_loader/_extensions/_utils.py similarity index 95% rename from snapcraft/internal/project_loader/_extensions/_utils.py rename to snapcraft_legacy/internal/project_loader/_extensions/_utils.py index f303644ef9..1a8d651d54 100644 --- a/snapcraft/internal/project_loader/_extensions/_utils.py +++ b/snapcraft_legacy/internal/project_loader/_extensions/_utils.py @@ -25,8 +25,8 @@ import jsonschema -import snapcraft.yaml_utils.errors -from snapcraft.project import errors as project_errors +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.project import errors as project_errors from .. import errors from ._extension import Extension @@ -94,7 +94,7 @@ def find_extension(extension_name: str) -> Type[Extension]: try: extension_module = importlib.import_module( - "snapcraft.internal.project_loader._extensions.{}".format( + "snapcraft_legacy.internal.project_loader._extensions.{}".format( extension_name.replace("-", "_") ) ) @@ -227,9 +227,9 @@ def _validate_extension_format(extension_names): extension_names, extension_schema, format_checker=format_check ) except jsonschema.ValidationError as e: - raise snapcraft.yaml_utils.errors.YamlValidationError( + raise snapcraft_legacy.yaml_utils.errors.YamlValidationError( "The 'extensions' property does not match the required schema: {}".format( - snapcraft.yaml_utils.errors.YamlValidationError.from_validation_error( + snapcraft_legacy.yaml_utils.errors.YamlValidationError.from_validation_error( e ).message ) diff --git a/snapcraft/internal/project_loader/_extensions/flutter_beta.py b/snapcraft_legacy/internal/project_loader/_extensions/flutter_beta.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/flutter_beta.py rename to snapcraft_legacy/internal/project_loader/_extensions/flutter_beta.py diff --git a/snapcraft/internal/project_loader/_extensions/flutter_dev.py b/snapcraft_legacy/internal/project_loader/_extensions/flutter_dev.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/flutter_dev.py rename to snapcraft_legacy/internal/project_loader/_extensions/flutter_dev.py diff --git a/snapcraft/internal/project_loader/_extensions/flutter_master.py b/snapcraft_legacy/internal/project_loader/_extensions/flutter_master.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/flutter_master.py rename to snapcraft_legacy/internal/project_loader/_extensions/flutter_master.py diff --git a/snapcraft/internal/project_loader/_extensions/flutter_stable.py b/snapcraft_legacy/internal/project_loader/_extensions/flutter_stable.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/flutter_stable.py rename to snapcraft_legacy/internal/project_loader/_extensions/flutter_stable.py diff --git a/snapcraft/internal/project_loader/_extensions/gnome_3_28.py b/snapcraft_legacy/internal/project_loader/_extensions/gnome_3_28.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/gnome_3_28.py rename to snapcraft_legacy/internal/project_loader/_extensions/gnome_3_28.py diff --git a/snapcraft/internal/project_loader/_extensions/gnome_3_34.py b/snapcraft_legacy/internal/project_loader/_extensions/gnome_3_34.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/gnome_3_34.py rename to snapcraft_legacy/internal/project_loader/_extensions/gnome_3_34.py diff --git a/snapcraft/internal/project_loader/_extensions/gnome_3_38.py b/snapcraft_legacy/internal/project_loader/_extensions/gnome_3_38.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/gnome_3_38.py rename to snapcraft_legacy/internal/project_loader/_extensions/gnome_3_38.py diff --git a/snapcraft/internal/project_loader/_extensions/kde_neon.py b/snapcraft_legacy/internal/project_loader/_extensions/kde_neon.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/kde_neon.py rename to snapcraft_legacy/internal/project_loader/_extensions/kde_neon.py diff --git a/snapcraft/internal/project_loader/_extensions/ros1_noetic.py b/snapcraft_legacy/internal/project_loader/_extensions/ros1_noetic.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/ros1_noetic.py rename to snapcraft_legacy/internal/project_loader/_extensions/ros1_noetic.py diff --git a/snapcraft/internal/project_loader/_extensions/ros2_foxy.py b/snapcraft_legacy/internal/project_loader/_extensions/ros2_foxy.py similarity index 100% rename from snapcraft/internal/project_loader/_extensions/ros2_foxy.py rename to snapcraft_legacy/internal/project_loader/_extensions/ros2_foxy.py diff --git a/snapcraft/internal/project_loader/_parts_config.py b/snapcraft_legacy/internal/project_loader/_parts_config.py similarity index 96% rename from snapcraft/internal/project_loader/_parts_config.py rename to snapcraft_legacy/internal/project_loader/_parts_config.py index a9ba01d330..d27ec843fa 100644 --- a/snapcraft/internal/project_loader/_parts_config.py +++ b/snapcraft_legacy/internal/project_loader/_parts_config.py @@ -20,9 +20,9 @@ from typing import Set # noqa: F401 from typing import List -import snapcraft -from snapcraft.internal import elf, pluginhandler, repo -from snapcraft.internal.pluginhandler._part_environment import ( +import snapcraft_legacy +from snapcraft_legacy.internal import elf, pluginhandler, repo +from snapcraft_legacy.internal.pluginhandler._part_environment import ( get_snapcraft_global_environment, get_snapcraft_part_directory_environment, ) @@ -167,7 +167,7 @@ def clean_part(self, part_name, staged_state, primed_state, step): def validate(self, part_names): for part_name in part_names: if part_name not in self._part_names: - raise snapcraft.internal.errors.SnapcraftEnvironmentError( + raise snapcraft_legacy.internal.errors.SnapcraftEnvironmentError( "The part named {!r} is not defined in " "{!r}".format( part_name, self._project.info.snapcraft_yaml_file_path @@ -194,7 +194,8 @@ def load_part(self, part_name, plugin_name, part_properties): grammar_processor = grammar_processing.PartGrammarProcessor( plugin=plugin, properties=part_properties, - project=self._project, + arch=self._project.deb_arch, + target_arch=self._project.target_arch, repo=stage_packages_repo, ) diff --git a/snapcraft/internal/project_loader/errors.py b/snapcraft_legacy/internal/project_loader/errors.py similarity index 95% rename from snapcraft/internal/project_loader/errors.py rename to snapcraft_legacy/internal/project_loader/errors.py index 30a5643f42..87dfa7df4a 100644 --- a/snapcraft/internal/project_loader/errors.py +++ b/snapcraft_legacy/internal/project_loader/errors.py @@ -16,10 +16,10 @@ import pathlib -import snapcraft.internal.errors +import snapcraft_legacy.internal.errors -class ProjectLoaderError(snapcraft.internal.errors.SnapcraftError): +class ProjectLoaderError(snapcraft_legacy.internal.errors.SnapcraftError): fmt = "" @@ -123,7 +123,9 @@ def __init__(self, part_name, after_part_name): super().__init__(part_name=part_name, after_part_name=after_part_name) -class SnapcraftProjectUnusedKeyAssetError(snapcraft.internal.errors.SnapcraftException): +class SnapcraftProjectUnusedKeyAssetError( + snapcraft_legacy.internal.errors.SnapcraftException +): def __init__(self, key_path: pathlib.Path): self.key_path = key_path diff --git a/snapcraft/internal/project_loader/grammar_processing/__init__.py b/snapcraft_legacy/internal/project_loader/grammar_processing/__init__.py similarity index 100% rename from snapcraft/internal/project_loader/grammar_processing/__init__.py rename to snapcraft_legacy/internal/project_loader/grammar_processing/__init__.py diff --git a/snapcraft/internal/project_loader/grammar_processing/_global_grammar_processor.py b/snapcraft_legacy/internal/project_loader/grammar_processing/_global_grammar_processor.py similarity index 63% rename from snapcraft/internal/project_loader/grammar_processing/_global_grammar_processor.py rename to snapcraft_legacy/internal/project_loader/grammar_processing/_global_grammar_processor.py index 2725665b64..38b1cb5307 100644 --- a/snapcraft/internal/project_loader/grammar_processing/_global_grammar_processor.py +++ b/snapcraft_legacy/internal/project_loader/grammar_processing/_global_grammar_processor.py @@ -16,37 +16,43 @@ from typing import Any, Dict, Set -from snapcraft import project -from snapcraft.internal import repo -from snapcraft.internal.project_loader import grammar +from craft_grammar import GrammarProcessor + +from snapcraft_legacy import project +from snapcraft_legacy.internal import repo class GlobalGrammarProcessor: """Process global properties that support grammar. Build packages example: - >>> import snapcraft - >>> from snapcraft import repo + >>> import snapcraft_legacy + >>> from snapcraft_legacy import repo >>> processor = GlobalGrammarProcessor( ... properties={'build-packages': [{'try': ['hello']}]}, - ... project=snapcraft.project.Project()) + ... project=snapcraft_legacy.project.Project()) >>> processor.get_build_packages() {'hello'} """ - def __init__(self, *, properties: Dict[str, Any], project: project.Project) -> None: - self._project = project + def __init__( + self, *, properties: Dict[str, Any], arch: str, target_arch: str + ) -> None: + self._arch = arch + self._target_arch = target_arch self._build_package_grammar = properties.get("build-packages", []) self.__build_packages = set() # type: Set[str] def get_build_packages(self) -> Set[str]: if not self.__build_packages: - processor = grammar.GrammarProcessor( - self._build_package_grammar, - self._project, - repo.Repo.build_package_is_valid, + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=repo.Repo.build_package_is_valid, + ) + self.__build_packages = set( + processor.process(grammar=self._build_package_grammar) ) - self.__build_packages = set(processor.process()) return self.__build_packages diff --git a/snapcraft/internal/project_loader/grammar_processing/_package_transformer.py b/snapcraft_legacy/internal/project_loader/grammar_processing/_package_transformer.py similarity index 82% rename from snapcraft/internal/project_loader/grammar_processing/_package_transformer.py rename to snapcraft_legacy/internal/project_loader/grammar_processing/_package_transformer.py index 03f6f22891..8b32d90ba4 100644 --- a/snapcraft/internal/project_loader/grammar_processing/_package_transformer.py +++ b/snapcraft_legacy/internal/project_loader/grammar_processing/_package_transformer.py @@ -14,13 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft import project -from snapcraft.internal.project_loader.grammar import ( - CompoundStatement, - Statement, - ToStatement, - typing, -) +from craft_grammar import CallStack, CompoundStatement, Statement, ToStatement def _is_or_contains_to_statement(statement: Statement) -> bool: @@ -37,11 +31,11 @@ def _is_or_contains_to_statement(statement: Statement) -> bool: def package_transformer( - call_stack: typing.CallStack, package_name: str, project: project.Project + call_stack: CallStack, package_name: str, target_arch: str ) -> str: if any(_is_or_contains_to_statement(s) for s in call_stack): if ":" not in package_name: # deb_arch is target arch or host arch if both are the same - package_name += ":{}".format(project.deb_arch) + package_name = f"{package_name}:{target_arch}" return package_name diff --git a/snapcraft/internal/project_loader/grammar_processing/_part_grammar_processor.py b/snapcraft_legacy/internal/project_loader/grammar_processing/_part_grammar_processor.py similarity index 61% rename from snapcraft/internal/project_loader/grammar_processing/_part_grammar_processor.py rename to snapcraft_legacy/internal/project_loader/grammar_processing/_part_grammar_processor.py index 323e473f40..2ca6367d44 100644 --- a/snapcraft/internal/project_loader/grammar_processing/_part_grammar_processor.py +++ b/snapcraft_legacy/internal/project_loader/grammar_processing/_part_grammar_processor.py @@ -16,9 +16,10 @@ from typing import Any, Dict, List, Set -from snapcraft import BasePlugin, project -from snapcraft.internal import repo -from snapcraft.internal.project_loader import grammar +from craft_grammar import Grammar, GrammarProcessor + +from snapcraft_legacy import BasePlugin, project +from snapcraft_legacy.internal import repo from ._package_transformer import package_transformer @@ -28,7 +29,7 @@ class PartGrammarProcessor: Stage packages example: >>> from unittest import mock - >>> import snapcraft + >>> import snapcraft_legacy >>> # Pretend that all packages are valid >>> repo = mock.Mock() >>> repo.is_valid.return_value = True @@ -37,14 +38,14 @@ class PartGrammarProcessor: >>> processor = PartGrammarProcessor( ... plugin=plugin, ... properties={}, - ... project=snapcraft.project.Project(), + ... project=snapcraft_legacy.project.Project(), ... repo=repo) >>> processor.get_stage_packages() {'foo'} Build packages example: >>> from unittest import mock - >>> import snapcraft + >>> import snapcraft_legacy >>> # Pretend that all packages are valid >>> repo = mock.Mock() >>> repo.is_valid.return_value = True @@ -53,20 +54,20 @@ class PartGrammarProcessor: >>> processor = PartGrammarProcessor( ... plugin=plugin, ... properties={}, - ... project=snapcraft.project.Project(), + ... project=snapcraft_legacy.project.Project(), ... repo=repo) >>> processor.get_build_packages() {'foo'} Source example: >>> from unittest import mock - >>> import snapcraft + >>> import snapcraft_legacy >>> plugin = mock.Mock() >>> plugin.properties = {'source': [{'on amd64': 'foo'}, 'else fail']} >>> processor = PartGrammarProcessor( ... plugin=plugin, ... properties=plugin.properties, - ... project=snapcraft.project.Project(), + ... project=snapcraft_legacy.project.Project(), ... repo=None) >>> processor.get_source() 'foo' @@ -77,12 +78,14 @@ def __init__( *, plugin: BasePlugin, properties: Dict[str, Any], - project: project.Project, + arch: str, + target_arch: str, repo: "repo.Ubuntu" ) -> None: self._plugin = plugin self._properties = properties - self._project = project + self._arch = arch + self._target_arch = target_arch self._repo = repo self.__build_environment: List[Dict[str, str]] = list() @@ -103,70 +106,86 @@ def get_source(self) -> str: if not self.__source: # The grammar is array-based, even though we only support a single # source. - processor = grammar.GrammarProcessor( - self._source_grammar, self._project, lambda s: True + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=lambda s: True, ) - source_array = processor.process() + source_array = processor.process(grammar=self._source_grammar) if len(source_array) > 0: self.__source = source_array.pop() return self.__source - def _get_property(self, attr: str) -> grammar.typing.Grammar: + def _get_property(self, attr: str) -> Grammar: prop = self._properties.get(attr, set()) return getattr(self._plugin, attr.replace("-", "_"), prop) def get_build_environment(self) -> List[Dict[str, str]]: if not self.__build_environment: - processor = grammar.GrammarProcessor( - self._get_property("build-environment"), - self._project, - lambda x: True, + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=lambda s: True, + ) + self.__build_environment = processor.process( + grammar=self._get_property("build-environment"), ) - self.__build_environment = processor.process() return self.__build_environment def get_build_snaps(self) -> Set[str]: if not self.__build_snaps: - processor = grammar.GrammarProcessor( - self._get_property("build-snaps"), - self._project, - repo.snaps.SnapPackage.is_valid_snap, + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=repo.snaps.SnapPackage.is_valid_snap, + ) + self.__build_snaps = set( + processor.process(grammar=self._get_property("build-snaps")) ) - self.__build_snaps = set(processor.process()) return self.__build_snaps def get_stage_snaps(self) -> Set[str]: if not self.__stage_snaps: - processor = grammar.GrammarProcessor( - self._get_property("stage-snaps"), - self._project, - repo.snaps.SnapPackage.is_valid_snap, + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=repo.snaps.SnapPackage.is_valid_snap, + ) + self.__stage_snaps = set( + processor.process(grammar=self._get_property("stage-snaps")) ) - self.__stage_snaps = set(processor.process()) return self.__stage_snaps def get_build_packages(self) -> Set[str]: if not self.__build_packages: - processor = grammar.GrammarProcessor( - self._get_property("build-packages"), - self._project, - self._repo.build_package_is_valid, + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=self._repo.build_package_is_valid, + ) + self.__build_packages = set( + processor.process( + grammar=self._get_property("build-packages"), + ) ) - self.__build_packages = set(processor.process()) return self.__build_packages def get_stage_packages(self) -> Set[str]: if not self.__stage_packages: - processor = grammar.GrammarProcessor( - self._get_property("stage-packages"), - self._project, - self._repo.build_package_is_valid, + processor = GrammarProcessor( + arch=self._arch, + target_arch=self._target_arch, + checker=self._repo.build_package_is_valid, transformer=package_transformer, ) - self.__stage_packages = set(processor.process()) + self.__stage_packages = set( + processor.process( + grammar=self._get_property("stage-packages"), + ) + ) return self.__stage_packages diff --git a/snapcraft/internal/project_loader/inspection/__init__.py b/snapcraft_legacy/internal/project_loader/inspection/__init__.py similarity index 100% rename from snapcraft/internal/project_loader/inspection/__init__.py rename to snapcraft_legacy/internal/project_loader/inspection/__init__.py diff --git a/snapcraft/internal/project_loader/inspection/_latest_step.py b/snapcraft_legacy/internal/project_loader/inspection/_latest_step.py similarity index 89% rename from snapcraft/internal/project_loader/inspection/_latest_step.py rename to snapcraft_legacy/internal/project_loader/inspection/_latest_step.py index d7510abc72..2bb99436a9 100644 --- a/snapcraft/internal/project_loader/inspection/_latest_step.py +++ b/snapcraft_legacy/internal/project_loader/inspection/_latest_step.py @@ -17,8 +17,8 @@ import contextlib from typing import List, Tuple -import snapcraft.internal.errors -from snapcraft.internal import pluginhandler, steps +import snapcraft_legacy.internal.errors +from snapcraft_legacy.internal import pluginhandler, steps from . import errors @@ -35,7 +35,7 @@ def latest_step( latest_step = None latest_timestamp = 0 for part in parts: - with contextlib.suppress(snapcraft.internal.errors.NoLatestStepError): + with contextlib.suppress(snapcraft_legacy.internal.errors.NoLatestStepError): step = part.latest_step() timestamp = part.step_timestamp(step) if latest_timestamp < timestamp: diff --git a/snapcraft/internal/project_loader/inspection/_lifecycle_status.py b/snapcraft_legacy/internal/project_loader/inspection/_lifecycle_status.py similarity index 94% rename from snapcraft/internal/project_loader/inspection/_lifecycle_status.py rename to snapcraft_legacy/internal/project_loader/inspection/_lifecycle_status.py index 5931f84316..ad0eb7193f 100644 --- a/snapcraft/internal/project_loader/inspection/_lifecycle_status.py +++ b/snapcraft_legacy/internal/project_loader/inspection/_lifecycle_status.py @@ -16,8 +16,8 @@ from typing import Dict, List -from snapcraft.internal import lifecycle, steps -from snapcraft.internal.project_loader import _config +from snapcraft_legacy.internal import lifecycle, steps +from snapcraft_legacy.internal.project_loader import _config def lifecycle_status(config: _config.Config) -> List[Dict[str, str]]: diff --git a/snapcraft/internal/project_loader/inspection/_provides.py b/snapcraft_legacy/internal/project_loader/inspection/_provides.py similarity index 97% rename from snapcraft/internal/project_loader/inspection/_provides.py rename to snapcraft_legacy/internal/project_loader/inspection/_provides.py index b83ec70358..2ad9068c96 100644 --- a/snapcraft/internal/project_loader/inspection/_provides.py +++ b/snapcraft_legacy/internal/project_loader/inspection/_provides.py @@ -17,8 +17,8 @@ import os from typing import Callable, Iterable, Optional, Set, Tuple, Union -from snapcraft import project -from snapcraft.internal import pluginhandler, states, steps +from snapcraft_legacy import project +from snapcraft_legacy.internal import pluginhandler, states, steps from . import errors diff --git a/snapcraft/internal/project_loader/inspection/errors.py b/snapcraft_legacy/internal/project_loader/inspection/errors.py similarity index 89% rename from snapcraft/internal/project_loader/inspection/errors.py rename to snapcraft_legacy/internal/project_loader/inspection/errors.py index b0da97398d..7358972f45 100644 --- a/snapcraft/internal/project_loader/inspection/errors.py +++ b/snapcraft_legacy/internal/project_loader/inspection/errors.py @@ -14,10 +14,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.internal.errors +import snapcraft_legacy.internal.errors -class NoSuchFileError(snapcraft.internal.errors.SnapcraftError): +class NoSuchFileError(snapcraft_legacy.internal.errors.SnapcraftError): fmt = ( "Failed to find part that provided path: {path!r} does not " @@ -29,7 +29,7 @@ def __init__(self, path): super().__init__(path=path) -class SnapcraftInspectError(snapcraft.internal.errors.SnapcraftError): +class SnapcraftInspectError(snapcraft_legacy.internal.errors.SnapcraftError): # Use a different exit code for these errors so the orchestrating snapcraft can # differentiate them. def get_exit_code(self): diff --git a/snapcraft/internal/remote_build/__init__.py b/snapcraft_legacy/internal/remote_build/__init__.py similarity index 100% rename from snapcraft/internal/remote_build/__init__.py rename to snapcraft_legacy/internal/remote_build/__init__.py diff --git a/snapcraft/internal/remote_build/_info_file.py b/snapcraft_legacy/internal/remote_build/_info_file.py similarity index 97% rename from snapcraft/internal/remote_build/_info_file.py rename to snapcraft_legacy/internal/remote_build/_info_file.py index 685cc46971..66e2c25288 100644 --- a/snapcraft/internal/remote_build/_info_file.py +++ b/snapcraft_legacy/internal/remote_build/_info_file.py @@ -17,7 +17,7 @@ import os from typing import Any -from snapcraft import yaml_utils +from snapcraft_legacy import yaml_utils class InfoFile(dict): diff --git a/snapcraft/internal/remote_build/_launchpad.py b/snapcraft_legacy/internal/remote_build/_launchpad.py similarity index 97% rename from snapcraft/internal/remote_build/_launchpad.py rename to snapcraft_legacy/internal/remote_build/_launchpad.py index 0f1512f2b9..ec168c0857 100644 --- a/snapcraft/internal/remote_build/_launchpad.py +++ b/snapcraft_legacy/internal/remote_build/_launchpad.py @@ -29,10 +29,10 @@ from lazr.restfulclient.resource import Entry from xdg import BaseDirectory -import snapcraft -from snapcraft.internal.sources._git import Git -from snapcraft.internal.sources.errors import SnapcraftPullError -from snapcraft.project import Project +import snapcraft_legacy +from snapcraft_legacy.internal.sources._git import Git +from snapcraft_legacy.internal.sources.errors import SnapcraftPullError +from snapcraft_legacy.project import Project from . import errors @@ -91,7 +91,7 @@ def __init__( snapcraft_channel: str = "stable", deadline: int = 0, git_class: Type[Git] = Git, - running_snapcraft_version: str = snapcraft.__version__, + running_snapcraft_version: str = snapcraft_legacy.__version__, ) -> None: self._git_class = git_class if not self._git_class.check_command_installed(): @@ -244,7 +244,7 @@ def _wait_for_build_request_acceptance( def login(self) -> Launchpad: try: return Launchpad.login_with( - "snapcraft remote-build {}".format(snapcraft.__version__), + "snapcraft remote-build {}".format(snapcraft_legacy.__version__), "production", self._cache_dir, credentials_file=self._credentials, diff --git a/snapcraft/internal/remote_build/_worktree.py b/snapcraft_legacy/internal/remote_build/_worktree.py similarity index 96% rename from snapcraft/internal/remote_build/_worktree.py rename to snapcraft_legacy/internal/remote_build/_worktree.py index 28b31e692c..465e381a28 100644 --- a/snapcraft/internal/remote_build/_worktree.py +++ b/snapcraft_legacy/internal/remote_build/_worktree.py @@ -22,13 +22,13 @@ from collections import OrderedDict from copy import deepcopy -import snapcraft -import snapcraft.internal.sources -from snapcraft import yaml_utils -from snapcraft.file_utils import rmtree -from snapcraft.internal.meta import _version -from snapcraft.internal.remote_build import errors -from snapcraft.project import Project +import snapcraft_legacy +import snapcraft_legacy.internal.sources +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.file_utils import rmtree +from snapcraft_legacy.internal.meta import _version +from snapcraft_legacy.internal.remote_build import errors +from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) @@ -88,7 +88,7 @@ def _get_part_source_handler(self, part_name: str, source: str, source_dir: str) part_config["source"] = source source_type = part_config.get("source-type") - handler_class = snapcraft.internal.sources.get_source_handler( + handler_class = snapcraft_legacy.internal.sources.get_source_handler( source, source_type=source_type ) return handler_class( @@ -170,9 +170,9 @@ def _pull_source(self, part_name: str, source: str, selector=None) -> str: # Skip non-local sources (the remote builder can fetch those directly), # unless configured to package all sources. is_local_source = isinstance( - source_handler, snapcraft.internal.sources.Local + source_handler, snapcraft_legacy.internal.sources.Local ) or ( - isinstance(source_handler, snapcraft.internal.sources.Git) + isinstance(source_handler, snapcraft_legacy.internal.sources.Git) and source_handler.is_local() ) if not self._package_all_sources and not is_local_source: diff --git a/snapcraft/internal/remote_build/errors.py b/snapcraft_legacy/internal/remote_build/errors.py similarity index 97% rename from snapcraft/internal/remote_build/errors.py rename to snapcraft_legacy/internal/remote_build/errors.py index 7c546d17aa..c276d94196 100644 --- a/snapcraft/internal/remote_build/errors.py +++ b/snapcraft_legacy/internal/remote_build/errors.py @@ -16,8 +16,8 @@ from typing import List, Sequence # noqa: F401 -from snapcraft.internal.errors import SnapcraftError as _SnapcraftError -from snapcraft.internal.errors import SnapcraftException +from snapcraft_legacy.internal.errors import SnapcraftError as _SnapcraftError +from snapcraft_legacy.internal.errors import SnapcraftException class RemoteBuildBaseError(_SnapcraftError): diff --git a/snapcraft/internal/repo/__init__.py b/snapcraft_legacy/internal/repo/__init__.py similarity index 100% rename from snapcraft/internal/repo/__init__.py rename to snapcraft_legacy/internal/repo/__init__.py diff --git a/snapcraft/internal/repo/_base.py b/snapcraft_legacy/internal/repo/_base.py similarity index 95% rename from snapcraft/internal/repo/_base.py rename to snapcraft_legacy/internal/repo/_base.py index a2b7b4f850..bffcbc8f0a 100644 --- a/snapcraft/internal/repo/_base.py +++ b/snapcraft_legacy/internal/repo/_base.py @@ -26,8 +26,8 @@ import stat from typing import List, Optional, Set -from snapcraft import file_utils -from snapcraft.internal import mangling, xattrs +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import mangling, xattrs from . import errors @@ -72,7 +72,7 @@ def get_package_for_file(cls, file_path: str) -> str: :param str file_path: the absolute path to the file to search for. :returns: package name that provides file_path. :rtype: str - :raises snapcraft.repo.errors.FileProviderNotFound: + :raises snapcraft_legacy.repo.errors.FileProviderNotFound: if file_path is not provided by any package. """ raise errors.NoNativeBackendError() @@ -105,9 +105,9 @@ def refresh_build_packages(cls) -> None: """Refresh the build packages cache. If refreshing is not possible - snapcraft.repo.errors.CacheUpdateFailedError should be raised + snapcraft_legacy.repo.errors.CacheUpdateFailedError should be raised - :raises snapcraft.repo.errors.NoNativeBackendError: + :raises snapcraft_legacy.repo.errors.NoNativeBackendError: if the method is not implemented in the subclass. """ raise errors.NoNativeBackendError() @@ -123,17 +123,17 @@ def install_build_packages(cls, package_names: List[str]) -> List[str]: in the form "package=version". If one of the packages cannot be found - snapcraft.repo.errors.BuildPackageNotFoundError should be raised. + snapcraft_legacy.repo.errors.BuildPackageNotFoundError should be raised. If dependencies for a package cannot be resolved - snapcraft.repo.errors.PackageBrokenError should be raised. + snapcraft_legacy.repo.errors.PackageBrokenError should be raised. If installing a package on the host failed - snapcraft.repo.errors.BuildPackagesNotInstalledError should be raised. + snapcraft_legacy.repo.errors.BuildPackagesNotInstalledError should be raised. :param package_names: a list of package names to install. :type package_names: a list of strings. :return: a list with the packages installed and their versions. :rtype: list of strings. - :raises snapcraft.repo.errors.NoNativeBackendError: + :raises snapcraft_legacy.repo.errors.NoNativeBackendError: if the method is not implemented in the subclass. """ raise errors.NoNativeBackendError() diff --git a/snapcraft/internal/repo/_deb.py b/snapcraft_legacy/internal/repo/_deb.py similarity index 98% rename from snapcraft/internal/repo/_deb.py rename to snapcraft_legacy/internal/repo/_deb.py index f507040c4e..4e2e9c1241 100644 --- a/snapcraft/internal/repo/_deb.py +++ b/snapcraft_legacy/internal/repo/_deb.py @@ -27,8 +27,8 @@ from xdg import BaseDirectory -from snapcraft import file_utils -from snapcraft.internal.indicators import is_dumb_terminal +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal.indicators import is_dumb_terminal from . import errors from ._base import BaseRepo, get_pkg_name_parts @@ -375,11 +375,11 @@ def install_build_packages(cls, package_names: List[str]) -> List[str]: :type package_names: a list of strings. :return: a list with the packages installed and their versions. :rtype: list of strings. - :raises snapcraft.repo.errors.BuildPackageNotFoundError: + :raises snapcraft_legacy.repo.errors.BuildPackageNotFoundError: if one of the packages was not found. - :raises snapcraft.repo.errors.PackageBrokenError: + :raises snapcraft_legacy.repo.errors.PackageBrokenError: if dependencies for one of the packages cannot be resolved. - :raises snapcraft.repo.errors.BuildPackagesNotInstalledError: + :raises snapcraft_legacy.repo.errors.BuildPackagesNotInstalledError: if installing the packages on the host failed. """ install_required = False diff --git a/snapcraft/internal/repo/_platform.py b/snapcraft_legacy/internal/repo/_platform.py similarity index 90% rename from snapcraft/internal/repo/_platform.py rename to snapcraft_legacy/internal/repo/_platform.py index 0e0e575f4b..5b450636c6 100644 --- a/snapcraft/internal/repo/_platform.py +++ b/snapcraft_legacy/internal/repo/_platform.py @@ -16,8 +16,8 @@ import logging -from snapcraft.internal.errors import OsReleaseIdError -from snapcraft.internal.os_release import OsRelease +from snapcraft_legacy.internal.errors import OsReleaseIdError +from snapcraft_legacy.internal.os_release import OsRelease logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/repo/apt_cache.py b/snapcraft_legacy/internal/repo/apt_cache.py similarity index 98% rename from snapcraft/internal/repo/apt_cache.py rename to snapcraft_legacy/internal/repo/apt_cache.py index 7b48ed9563..11be8a58f5 100644 --- a/snapcraft/internal/repo/apt_cache.py +++ b/snapcraft_legacy/internal/repo/apt_cache.py @@ -24,10 +24,10 @@ import apt -from snapcraft.internal import common -from snapcraft.internal.indicators import is_dumb_terminal -from snapcraft.internal.repo import errors -from snapcraft.internal.repo._base import get_pkg_name_parts +from snapcraft_legacy.internal import common +from snapcraft_legacy.internal.indicators import is_dumb_terminal +from snapcraft_legacy.internal.repo import errors +from snapcraft_legacy.internal.repo._base import get_pkg_name_parts logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/repo/apt_key_manager.py b/snapcraft_legacy/internal/repo/apt_key_manager.py similarity index 99% rename from snapcraft/internal/repo/apt_key_manager.py rename to snapcraft_legacy/internal/repo/apt_key_manager.py index 85e446eace..0ea6f82a54 100644 --- a/snapcraft/internal/repo/apt_key_manager.py +++ b/snapcraft_legacy/internal/repo/apt_key_manager.py @@ -22,7 +22,7 @@ import gnupg -from snapcraft.internal.meta import package_repository +from snapcraft_legacy.internal.meta import package_repository from . import apt_ppa, errors diff --git a/snapcraft/internal/repo/apt_ppa.py b/snapcraft_legacy/internal/repo/apt_ppa.py similarity index 100% rename from snapcraft/internal/repo/apt_ppa.py rename to snapcraft_legacy/internal/repo/apt_ppa.py diff --git a/snapcraft/internal/repo/apt_sources_manager.py b/snapcraft_legacy/internal/repo/apt_sources_manager.py similarity index 97% rename from snapcraft/internal/repo/apt_sources_manager.py rename to snapcraft_legacy/internal/repo/apt_sources_manager.py index dbd92054bd..8d979c37c6 100644 --- a/snapcraft/internal/repo/apt_sources_manager.py +++ b/snapcraft_legacy/internal/repo/apt_sources_manager.py @@ -25,9 +25,9 @@ import tempfile from typing import List, Optional -from snapcraft.internal import os_release -from snapcraft.internal.meta import package_repository -from snapcraft.project._project_options import ProjectOptions +from snapcraft_legacy.internal import os_release +from snapcraft_legacy.internal.meta import package_repository +from snapcraft_legacy.project._project_options import ProjectOptions from . import apt_ppa diff --git a/snapcraft/internal/repo/deb_package.py b/snapcraft_legacy/internal/repo/deb_package.py similarity index 100% rename from snapcraft/internal/repo/deb_package.py rename to snapcraft_legacy/internal/repo/deb_package.py diff --git a/snapcraft/internal/repo/errors.py b/snapcraft_legacy/internal/repo/errors.py similarity index 97% rename from snapcraft/internal/repo/errors.py rename to snapcraft_legacy/internal/repo/errors.py index 98307acbc2..b06d3943ce 100644 --- a/snapcraft/internal/repo/errors.py +++ b/snapcraft_legacy/internal/repo/errors.py @@ -17,10 +17,10 @@ from pathlib import Path from typing import List, Optional, Sequence -from snapcraft import formatting_utils -from snapcraft.internal import errors -from snapcraft.internal.errors import SnapcraftException -from snapcraft.internal.os_release import OsRelease +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.errors import SnapcraftException +from snapcraft_legacy.internal.os_release import OsRelease from ._platform import _is_deb_based diff --git a/snapcraft/internal/repo/snaps.py b/snapcraft_legacy/internal/repo/snaps.py similarity index 100% rename from snapcraft/internal/repo/snaps.py rename to snapcraft_legacy/internal/repo/snaps.py diff --git a/snapcraft/internal/repo/ua_manager.py b/snapcraft_legacy/internal/repo/ua_manager.py similarity index 98% rename from snapcraft/internal/repo/ua_manager.py rename to snapcraft_legacy/internal/repo/ua_manager.py index 4f3c8c624e..de1dfadf3d 100644 --- a/snapcraft/internal/repo/ua_manager.py +++ b/snapcraft_legacy/internal/repo/ua_manager.py @@ -20,7 +20,7 @@ import subprocess from typing import Any, Dict, Iterator, Optional -from snapcraft.internal import repo +from snapcraft_legacy.internal import repo logger = logging.getLogger(__name__) diff --git a/snapcraft/internal/review_tools/__init__.py b/snapcraft_legacy/internal/review_tools/__init__.py similarity index 100% rename from snapcraft/internal/review_tools/__init__.py rename to snapcraft_legacy/internal/review_tools/__init__.py diff --git a/snapcraft/internal/review_tools/_runner.py b/snapcraft_legacy/internal/review_tools/_runner.py similarity index 98% rename from snapcraft/internal/review_tools/_runner.py rename to snapcraft_legacy/internal/review_tools/_runner.py index 2f7ff3bda4..e6b04db3c4 100644 --- a/snapcraft/internal/review_tools/_runner.py +++ b/snapcraft_legacy/internal/review_tools/_runner.py @@ -18,7 +18,7 @@ import pathlib import subprocess -from snapcraft import file_utils +from snapcraft_legacy import file_utils from . import errors diff --git a/snapcraft/internal/review_tools/errors.py b/snapcraft_legacy/internal/review_tools/errors.py similarity index 97% rename from snapcraft/internal/review_tools/errors.py rename to snapcraft_legacy/internal/review_tools/errors.py index b97362c0f3..6e7a674429 100644 --- a/snapcraft/internal/review_tools/errors.py +++ b/snapcraft_legacy/internal/review_tools/errors.py @@ -16,7 +16,7 @@ from typing import Any, Dict, Optional -from snapcraft.internal.errors import SnapcraftException +from snapcraft_legacy.internal.errors import SnapcraftException class ReviewToolMissing(SnapcraftException): diff --git a/snapcraft/internal/sources/_7z.py b/snapcraft_legacy/internal/sources/_7z.py similarity index 100% rename from snapcraft/internal/sources/_7z.py rename to snapcraft_legacy/internal/sources/_7z.py diff --git a/snapcraft/internal/sources/__init__.py b/snapcraft_legacy/internal/sources/__init__.py similarity index 100% rename from snapcraft/internal/sources/__init__.py rename to snapcraft_legacy/internal/sources/__init__.py diff --git a/snapcraft/internal/sources/_base.py b/snapcraft_legacy/internal/sources/_base.py similarity index 94% rename from snapcraft/internal/sources/_base.py rename to snapcraft_legacy/internal/sources/_base.py index 94fda53b89..6096d5a2b4 100644 --- a/snapcraft/internal/sources/_base.py +++ b/snapcraft_legacy/internal/sources/_base.py @@ -20,9 +20,9 @@ import requests -import snapcraft.internal.common -from snapcraft.internal.cache import FileCache -from snapcraft.internal.indicators import ( +import snapcraft_legacy.internal.common +from snapcraft_legacy.internal.cache import FileCache +from snapcraft_legacy.internal.indicators import ( download_requests_stream, download_urllib_source, ) @@ -106,7 +106,7 @@ def _run_output(self, command, **kwargs): class FileBase(Base): def pull(self): source_file = None - is_source_url = snapcraft.internal.common.isurl(self.source) + is_source_url = snapcraft_legacy.internal.common.isurl(self.source) # First check if it is a url and download and if not # it is probably locally referenced. @@ -148,7 +148,7 @@ def download(self, filepath: str = None) -> str: return self.file # If not we download and store - if snapcraft.internal.common.get_url_scheme(self.source) == "ftp": + if snapcraft_legacy.internal.common.get_url_scheme(self.source) == "ftp": download_urllib_source(self.source, self.file) else: try: diff --git a/snapcraft/internal/sources/_bazaar.py b/snapcraft_legacy/internal/sources/_bazaar.py similarity index 100% rename from snapcraft/internal/sources/_bazaar.py rename to snapcraft_legacy/internal/sources/_bazaar.py diff --git a/snapcraft/internal/sources/_checksum.py b/snapcraft_legacy/internal/sources/_checksum.py similarity index 97% rename from snapcraft/internal/sources/_checksum.py rename to snapcraft_legacy/internal/sources/_checksum.py index 4fd0012313..3961d7eec3 100644 --- a/snapcraft/internal/sources/_checksum.py +++ b/snapcraft_legacy/internal/sources/_checksum.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from typing import Tuple -from snapcraft.file_utils import calculate_hash +from snapcraft_legacy.file_utils import calculate_hash from . import errors diff --git a/snapcraft/internal/sources/_deb.py b/snapcraft_legacy/internal/sources/_deb.py similarity index 100% rename from snapcraft/internal/sources/_deb.py rename to snapcraft_legacy/internal/sources/_deb.py diff --git a/snapcraft/internal/sources/_git.py b/snapcraft_legacy/internal/sources/_git.py similarity index 99% rename from snapcraft/internal/sources/_git.py rename to snapcraft_legacy/internal/sources/_git.py index 7ed32b4df3..13ef300713 100644 --- a/snapcraft/internal/sources/_git.py +++ b/snapcraft_legacy/internal/sources/_git.py @@ -261,7 +261,7 @@ def add(self, file): command = [self.command, "-C", self.source_dir, "add", file] self._run_git_command(command) - def commit(self, message, author="snapcraft "): + def commit(self, message, author="snapcraft "): command = [ self.command, "-C", diff --git a/snapcraft/internal/sources/_local.py b/snapcraft_legacy/internal/sources/_local.py similarity index 98% rename from snapcraft/internal/sources/_local.py rename to snapcraft_legacy/internal/sources/_local.py index e54b56d8b3..a658d388c0 100644 --- a/snapcraft/internal/sources/_local.py +++ b/snapcraft_legacy/internal/sources/_local.py @@ -19,8 +19,8 @@ import glob import os -from snapcraft import file_utils -from snapcraft.internal import common +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import common from ._base import Base diff --git a/snapcraft/internal/sources/_mercurial.py b/snapcraft_legacy/internal/sources/_mercurial.py similarity index 100% rename from snapcraft/internal/sources/_mercurial.py rename to snapcraft_legacy/internal/sources/_mercurial.py diff --git a/snapcraft/internal/sources/_rpm.py b/snapcraft_legacy/internal/sources/_rpm.py similarity index 100% rename from snapcraft/internal/sources/_rpm.py rename to snapcraft_legacy/internal/sources/_rpm.py diff --git a/snapcraft/internal/sources/_script.py b/snapcraft_legacy/internal/sources/_script.py similarity index 100% rename from snapcraft/internal/sources/_script.py rename to snapcraft_legacy/internal/sources/_script.py diff --git a/snapcraft/internal/sources/_snap.py b/snapcraft_legacy/internal/sources/_snap.py similarity index 98% rename from snapcraft/internal/sources/_snap.py rename to snapcraft_legacy/internal/sources/_snap.py index b8705f111d..5144376876 100644 --- a/snapcraft/internal/sources/_snap.py +++ b/snapcraft_legacy/internal/sources/_snap.py @@ -18,7 +18,7 @@ import shutil import tempfile -from snapcraft import file_utils, yaml_utils +from snapcraft_legacy import file_utils, yaml_utils from . import errors from ._base import FileBase diff --git a/snapcraft/internal/sources/_subversion.py b/snapcraft_legacy/internal/sources/_subversion.py similarity index 100% rename from snapcraft/internal/sources/_subversion.py rename to snapcraft_legacy/internal/sources/_subversion.py diff --git a/snapcraft/internal/sources/_tar.py b/snapcraft_legacy/internal/sources/_tar.py similarity index 100% rename from snapcraft/internal/sources/_tar.py rename to snapcraft_legacy/internal/sources/_tar.py diff --git a/snapcraft/internal/sources/_zip.py b/snapcraft_legacy/internal/sources/_zip.py similarity index 100% rename from snapcraft/internal/sources/_zip.py rename to snapcraft_legacy/internal/sources/_zip.py diff --git a/snapcraft/internal/sources/errors.py b/snapcraft_legacy/internal/sources/errors.py similarity index 98% rename from snapcraft/internal/sources/errors.py rename to snapcraft_legacy/internal/sources/errors.py index ec612171cf..aba3d79630 100644 --- a/snapcraft/internal/sources/errors.py +++ b/snapcraft_legacy/internal/sources/errors.py @@ -17,8 +17,8 @@ import shlex from typing import List -from snapcraft import formatting_utils -from snapcraft.internal import errors +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import errors class SnapcraftSourceError(errors.SnapcraftError): diff --git a/snapcraft/internal/states/__init__.py b/snapcraft_legacy/internal/states/__init__.py similarity index 53% rename from snapcraft/internal/states/__init__.py rename to snapcraft_legacy/internal/states/__init__.py index c1f6e1251e..0a88af5f71 100644 --- a/snapcraft/internal/states/__init__.py +++ b/snapcraft_legacy/internal/states/__init__.py @@ -14,11 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.states._build_state import BuildState # noqa -from snapcraft.internal.states._global_state import GlobalState # noqa -from snapcraft.internal.states._prime_state import PrimeState # noqa -from snapcraft.internal.states._pull_state import PullState # noqa -from snapcraft.internal.states._stage_state import StageState # noqa -from snapcraft.internal.states._state import PartState # noqa -from snapcraft.internal.states._state import get_state # noqa -from snapcraft.internal.states._state import get_step_state_file # noqa +from snapcraft_legacy.internal.states._build_state import BuildState # noqa +from snapcraft_legacy.internal.states._global_state import GlobalState # noqa +from snapcraft_legacy.internal.states._prime_state import PrimeState # noqa +from snapcraft_legacy.internal.states._pull_state import PullState # noqa +from snapcraft_legacy.internal.states._stage_state import StageState # noqa +from snapcraft_legacy.internal.states._state import PartState # noqa +from snapcraft_legacy.internal.states._state import get_state # noqa +from snapcraft_legacy.internal.states._state import get_step_state_file # noqa diff --git a/snapcraft/internal/states/_build_state.py b/snapcraft_legacy/internal/states/_build_state.py similarity index 91% rename from snapcraft/internal/states/_build_state.py rename to snapcraft_legacy/internal/states/_build_state.py index fa3db8077b..d83b049d62 100644 --- a/snapcraft/internal/states/_build_state.py +++ b/snapcraft_legacy/internal/states/_build_state.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.extractors -from snapcraft.internal.states._state import PartState +import snapcraft_legacy.extractors +from snapcraft_legacy.internal.states._state import PartState def _schema_properties(): @@ -55,10 +55,10 @@ def __init__( self.assets.update(machine_assets) if not scriptlet_metadata: - scriptlet_metadata = snapcraft.extractors.ExtractedMetadata() + scriptlet_metadata = snapcraft_legacy.extractors.ExtractedMetadata() if not metadata: - metadata = snapcraft.extractors.ExtractedMetadata() + metadata = snapcraft_legacy.extractors.ExtractedMetadata() if not metadata_files: metadata_files = [] diff --git a/snapcraft/internal/states/_global_state.py b/snapcraft_legacy/internal/states/_global_state.py similarity index 96% rename from snapcraft/internal/states/_global_state.py rename to snapcraft_legacy/internal/states/_global_state.py index 1852aa9f05..4d3468579f 100644 --- a/snapcraft/internal/states/_global_state.py +++ b/snapcraft_legacy/internal/states/_global_state.py @@ -19,8 +19,8 @@ from mypy_extensions import TypedDict -from snapcraft import yaml_utils -from snapcraft.internal.states._state import State +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal.states._state import State StateDict = TypedDict( "StateDict", diff --git a/snapcraft/internal/states/_prime_state.py b/snapcraft_legacy/internal/states/_prime_state.py similarity index 92% rename from snapcraft/internal/states/_prime_state.py rename to snapcraft_legacy/internal/states/_prime_state.py index a17bccce55..ab137b5c73 100644 --- a/snapcraft/internal/states/_prime_state.py +++ b/snapcraft_legacy/internal/states/_prime_state.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.extractors -from snapcraft.internal.states._state import PartState +import snapcraft_legacy.extractors +from snapcraft_legacy.internal.states._state import PartState class PrimeState(PartState): @@ -34,7 +34,7 @@ def __init__( super().__init__(part_properties, project) if not scriptlet_metadata: - scriptlet_metadata = snapcraft.extractors.ExtractedMetadata() + scriptlet_metadata = snapcraft_legacy.extractors.ExtractedMetadata() self.files = files self.directories = directories diff --git a/snapcraft/internal/states/_pull_state.py b/snapcraft_legacy/internal/states/_pull_state.py similarity index 91% rename from snapcraft/internal/states/_pull_state.py rename to snapcraft_legacy/internal/states/_pull_state.py index 90ec8dfc02..610903074b 100644 --- a/snapcraft/internal/states/_pull_state.py +++ b/snapcraft_legacy/internal/states/_pull_state.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.extractors -from snapcraft.internal.states._state import PartState +import snapcraft_legacy.extractors +from snapcraft_legacy.internal.states._state import PartState def _schema_properties(): @@ -63,10 +63,10 @@ def __init__( } if not scriptlet_metadata: - scriptlet_metadata = snapcraft.extractors.ExtractedMetadata() + scriptlet_metadata = snapcraft_legacy.extractors.ExtractedMetadata() if not metadata: - metadata = snapcraft.extractors.ExtractedMetadata() + metadata = snapcraft_legacy.extractors.ExtractedMetadata() if not metadata_files: metadata_files = [] diff --git a/snapcraft/internal/states/_stage_state.py b/snapcraft_legacy/internal/states/_stage_state.py similarity index 91% rename from snapcraft/internal/states/_stage_state.py rename to snapcraft_legacy/internal/states/_stage_state.py index 835afbe531..0adb20c6bd 100644 --- a/snapcraft/internal/states/_stage_state.py +++ b/snapcraft_legacy/internal/states/_stage_state.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.extractors -from snapcraft.internal.states._state import PartState +import snapcraft_legacy.extractors +from snapcraft_legacy.internal.states._state import PartState class StageState(PartState): @@ -32,7 +32,7 @@ def __init__( super().__init__(part_properties, project) if not scriptlet_metadata: - scriptlet_metadata = snapcraft.extractors.ExtractedMetadata() + scriptlet_metadata = snapcraft_legacy.extractors.ExtractedMetadata() self.files = files self.directories = directories diff --git a/snapcraft/internal/states/_state.py b/snapcraft_legacy/internal/states/_state.py similarity index 97% rename from snapcraft/internal/states/_state.py rename to snapcraft_legacy/internal/states/_state.py index 3794975e31..70e4cb8bd2 100644 --- a/snapcraft/internal/states/_state.py +++ b/snapcraft_legacy/internal/states/_state.py @@ -16,8 +16,8 @@ import os -from snapcraft import yaml_utils -from snapcraft.internal import steps +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal import steps class State(yaml_utils.SnapcraftYAMLObject): diff --git a/snapcraft/internal/steps.py b/snapcraft_legacy/internal/steps.py similarity index 98% rename from snapcraft/internal/steps.py rename to snapcraft_legacy/internal/steps.py index a860bae7f2..4b6dd146d9 100644 --- a/snapcraft/internal/steps.py +++ b/snapcraft_legacy/internal/steps.py @@ -16,7 +16,7 @@ from typing import List, Optional -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors class Step: diff --git a/snapcraft/internal/xattrs.py b/snapcraft_legacy/internal/xattrs.py similarity index 97% rename from snapcraft/internal/xattrs.py rename to snapcraft_legacy/internal/xattrs.py index 0eca53e41b..9167a33eff 100644 --- a/snapcraft/internal/xattrs.py +++ b/snapcraft_legacy/internal/xattrs.py @@ -19,7 +19,7 @@ import sys from typing import Optional -from snapcraft.internal.errors import XAttributeError, XAttributeTooLongError +from snapcraft_legacy.internal.errors import XAttributeError, XAttributeTooLongError def _get_snapcraft_xattr_key(snapcraft_key: str) -> str: diff --git a/snapcraft/plugins/__init__.py b/snapcraft_legacy/plugins/__init__.py similarity index 100% rename from snapcraft/plugins/__init__.py rename to snapcraft_legacy/plugins/__init__.py diff --git a/snapcraft/plugins/_plugin_finder.py b/snapcraft_legacy/plugins/_plugin_finder.py similarity index 98% rename from snapcraft/plugins/_plugin_finder.py rename to snapcraft_legacy/plugins/_plugin_finder.py index 3504132162..921a26d9d7 100644 --- a/snapcraft/plugins/_plugin_finder.py +++ b/snapcraft_legacy/plugins/_plugin_finder.py @@ -17,7 +17,7 @@ import sys from typing import TYPE_CHECKING, Dict, Type, Union -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors from . import v1, v2 diff --git a/snapcraft/plugins/_python/__init__.py b/snapcraft_legacy/plugins/_python/__init__.py similarity index 100% rename from snapcraft/plugins/_python/__init__.py rename to snapcraft_legacy/plugins/_python/__init__.py diff --git a/snapcraft/plugins/v1/__init__.py b/snapcraft_legacy/plugins/v1/__init__.py similarity index 100% rename from snapcraft/plugins/v1/__init__.py rename to snapcraft_legacy/plugins/v1/__init__.py diff --git a/snapcraft/plugins/v1/_plugin.py b/snapcraft_legacy/plugins/v1/_plugin.py similarity index 97% rename from snapcraft/plugins/v1/_plugin.py rename to snapcraft_legacy/plugins/v1/_plugin.py index d1ee35d139..aeb0965221 100644 --- a/snapcraft/plugins/v1/_plugin.py +++ b/snapcraft_legacy/plugins/v1/_plugin.py @@ -21,9 +21,9 @@ from subprocess import CalledProcessError from typing import List -from snapcraft.project import Project -from snapcraft.internal import common, errors -from snapcraft.internal.meta.package_repository import PackageRepository +from snapcraft_legacy.project import Project +from snapcraft_legacy.internal import common, errors +from snapcraft_legacy.internal.meta.package_repository import PackageRepository logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/_python/__init__.py b/snapcraft_legacy/plugins/v1/_python/__init__.py similarity index 100% rename from snapcraft/plugins/v1/_python/__init__.py rename to snapcraft_legacy/plugins/v1/_python/__init__.py diff --git a/snapcraft/plugins/v1/_python/_pip.py b/snapcraft_legacy/plugins/v1/_python/_pip.py similarity index 98% rename from snapcraft/plugins/v1/_python/_pip.py rename to snapcraft_legacy/plugins/v1/_python/_pip.py index 7c0bedf65e..9c9dd27cae 100644 --- a/snapcraft/plugins/v1/_python/_pip.py +++ b/snapcraft_legacy/plugins/v1/_python/_pip.py @@ -27,9 +27,9 @@ import tempfile from typing import Dict, List, Optional, Sequence, Set -import snapcraft -from snapcraft import file_utils -from snapcraft.internal import mangling +import snapcraft_legacy +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import mangling from . import errors from ._python_finder import get_python_command, get_python_headers, get_python_home @@ -529,11 +529,13 @@ def _run(self, args, runner=None, **kwargs): # Using None as the default value instead of common.run so we can mock # common.run. if runner is None: - runner = snapcraft.internal.common.run + runner = snapcraft_legacy.internal.common.run return runner( [self._python_command, "-m", "pip"] + list(args), env=env, **kwargs ) def _run_output(self, args, **kwargs): - return self._run(args, runner=snapcraft.internal.common.run_output, **kwargs) + return self._run( + args, runner=snapcraft_legacy.internal.common.run_output, **kwargs + ) diff --git a/snapcraft/plugins/v1/_python/_python_finder.py b/snapcraft_legacy/plugins/v1/_python/_python_finder.py similarity index 100% rename from snapcraft/plugins/v1/_python/_python_finder.py rename to snapcraft_legacy/plugins/v1/_python/_python_finder.py diff --git a/snapcraft/plugins/v1/_python/_sitecustomize.py b/snapcraft_legacy/plugins/v1/_python/_sitecustomize.py similarity index 100% rename from snapcraft/plugins/v1/_python/_sitecustomize.py rename to snapcraft_legacy/plugins/v1/_python/_sitecustomize.py diff --git a/snapcraft/plugins/v1/_python/errors.py b/snapcraft_legacy/plugins/v1/_python/errors.py similarity index 90% rename from snapcraft/plugins/v1/_python/errors.py rename to snapcraft_legacy/plugins/v1/_python/errors.py index 62ad545878..84315c1a7e 100644 --- a/snapcraft/plugins/v1/_python/errors.py +++ b/snapcraft_legacy/plugins/v1/_python/errors.py @@ -14,11 +14,11 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.formatting_utils -import snapcraft.internal.errors +import snapcraft_legacy.formatting_utils +import snapcraft_legacy.internal.errors -class PythonPluginError(snapcraft.internal.errors.SnapcraftError): +class PythonPluginError(snapcraft_legacy.internal.errors.SnapcraftError): pass @@ -29,7 +29,7 @@ class MissingPythonCommandError(PythonPluginError): def __init__(self, python_version, search_paths): super().__init__( python_version=python_version, - search_paths=snapcraft.formatting_utils.combine_paths( + search_paths=snapcraft_legacy.formatting_utils.combine_paths( search_paths, "", ":" ), ) diff --git a/snapcraft/plugins/v1/_ros/__init__.py b/snapcraft_legacy/plugins/v1/_ros/__init__.py similarity index 100% rename from snapcraft/plugins/v1/_ros/__init__.py rename to snapcraft_legacy/plugins/v1/_ros/__init__.py diff --git a/snapcraft/plugins/v1/_ros/rosdep.py b/snapcraft_legacy/plugins/v1/_ros/rosdep.py similarity index 99% rename from snapcraft/plugins/v1/_ros/rosdep.py rename to snapcraft_legacy/plugins/v1/_ros/rosdep.py index 1e3fd9f31a..c2e9da2be1 100644 --- a/snapcraft/plugins/v1/_ros/rosdep.py +++ b/snapcraft_legacy/plugins/v1/_ros/rosdep.py @@ -23,7 +23,7 @@ import sys from typing import Dict, Set -from snapcraft.internal import errors, repo +from snapcraft_legacy.internal import errors, repo logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/_ros/wstool.py b/snapcraft_legacy/plugins/v1/_ros/wstool.py similarity index 93% rename from snapcraft/plugins/v1/_ros/wstool.py rename to snapcraft_legacy/plugins/v1/_ros/wstool.py index 160673fd63..c915250620 100644 --- a/snapcraft/plugins/v1/_ros/wstool.py +++ b/snapcraft_legacy/plugins/v1/_ros/wstool.py @@ -21,9 +21,9 @@ import sys from typing import List -import snapcraft -from snapcraft.internal import errors, repo -from snapcraft.project import Project +import snapcraft_legacy +from snapcraft_legacy.internal import errors, repo +from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) @@ -162,14 +162,15 @@ def _run(self, arguments: List[str]) -> str: if "LD_LIBRARY_PATH" in env: env["LD_LIBRARY_PATH"] += ":" ld_library_path = env.get("LD_LIBRARY_PATH", "") - env[ - "LD_LIBRARY_PATH" - ] = ld_library_path + snapcraft.formatting_utils.combine_paths( - snapcraft.common.get_library_paths( - self._wstool_install_path, self._project.arch_triplet - ), - prepend="", - separator=":", + env["LD_LIBRARY_PATH"] = ( + ld_library_path + + snapcraft_legacy.formatting_utils.combine_paths( + snapcraft_legacy.common.get_library_paths( + self._wstool_install_path, self._project.arch_triplet + ), + prepend="", + separator=":", + ) ) # Make sure git can be used out of the wstool install path instead of needing diff --git a/snapcraft/plugins/v1/ant.py b/snapcraft_legacy/plugins/v1/ant.py similarity index 98% rename from snapcraft/plugins/v1/ant.py rename to snapcraft_legacy/plugins/v1/ant.py index 35e6760127..11af6d4c3f 100644 --- a/snapcraft/plugins/v1/ant.py +++ b/snapcraft_legacy/plugins/v1/ant.py @@ -65,9 +65,9 @@ from typing import Sequence from urllib.parse import urlsplit -from snapcraft import formatting_utils -from snapcraft.internal import errors, sources -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal import errors, sources +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/autotools.py b/snapcraft_legacy/plugins/v1/autotools.py similarity index 99% rename from snapcraft/plugins/v1/autotools.py rename to snapcraft_legacy/plugins/v1/autotools.py index cd7f322682..0932c94c20 100644 --- a/snapcraft/plugins/v1/autotools.py +++ b/snapcraft_legacy/plugins/v1/autotools.py @@ -42,7 +42,7 @@ import os from pathlib import Path -from snapcraft.plugins.v1 import make +from snapcraft_legacy.plugins.v1 import make class AutotoolsPlugin(make.MakePlugin): diff --git a/snapcraft/plugins/v1/catkin.py b/snapcraft_legacy/plugins/v1/catkin.py similarity index 99% rename from snapcraft/plugins/v1/catkin.py rename to snapcraft_legacy/plugins/v1/catkin.py index 1023d8000b..227bb0f9d0 100644 --- a/snapcraft/plugins/v1/catkin.py +++ b/snapcraft_legacy/plugins/v1/catkin.py @@ -81,16 +81,16 @@ import textwrap from typing import TYPE_CHECKING, List, Set -from snapcraft import file_utils, formatting_utils -from snapcraft.internal import common, errors, mangling, os_release, repo -from snapcraft.internal.meta.package_repository import ( +from snapcraft_legacy import file_utils, formatting_utils +from snapcraft_legacy.internal import common, errors, mangling, os_release, repo +from snapcraft_legacy.internal.meta.package_repository import ( PackageRepository, PackageRepositoryApt, ) -from snapcraft.plugins.v1 import PluginV1, _python, _ros +from snapcraft_legacy.plugins.v1 import PluginV1, _python, _ros if TYPE_CHECKING: - from snapcraft.project import Project + from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/catkin_tools.py b/snapcraft_legacy/plugins/v1/catkin_tools.py similarity index 98% rename from snapcraft/plugins/v1/catkin_tools.py rename to snapcraft_legacy/plugins/v1/catkin_tools.py index 7ffd25470a..831848f260 100644 --- a/snapcraft/plugins/v1/catkin_tools.py +++ b/snapcraft_legacy/plugins/v1/catkin_tools.py @@ -25,7 +25,7 @@ import logging import os -from snapcraft.plugins.v1 import catkin +from snapcraft_legacy.plugins.v1 import catkin logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/cmake.py b/snapcraft_legacy/plugins/v1/cmake.py similarity index 99% rename from snapcraft/plugins/v1/cmake.py rename to snapcraft_legacy/plugins/v1/cmake.py index 9ba8c0ef44..a712a62e30 100644 --- a/snapcraft/plugins/v1/cmake.py +++ b/snapcraft_legacy/plugins/v1/cmake.py @@ -37,7 +37,7 @@ import os from typing import List, Optional -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(name=__name__) diff --git a/snapcraft/plugins/v1/colcon.py b/snapcraft_legacy/plugins/v1/colcon.py similarity index 99% rename from snapcraft/plugins/v1/colcon.py rename to snapcraft_legacy/plugins/v1/colcon.py index 8b077a7055..522e2db918 100644 --- a/snapcraft/plugins/v1/colcon.py +++ b/snapcraft_legacy/plugins/v1/colcon.py @@ -66,13 +66,13 @@ import textwrap from typing import List -from snapcraft import file_utils -from snapcraft.internal import errors, mangling, os_release, repo -from snapcraft.internal.meta.package_repository import ( +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import errors, mangling, os_release, repo +from snapcraft_legacy.internal.meta.package_repository import ( PackageRepository, PackageRepositoryApt, ) -from snapcraft.plugins.v1 import PluginV1, _python, _ros +from snapcraft_legacy.plugins.v1 import PluginV1, _python, _ros logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/conda.py b/snapcraft_legacy/plugins/v1/conda.py similarity index 97% rename from snapcraft/plugins/v1/conda.py rename to snapcraft_legacy/plugins/v1/conda.py index 7fcf7a696e..aa2bbaa9e9 100644 --- a/snapcraft/plugins/v1/conda.py +++ b/snapcraft_legacy/plugins/v1/conda.py @@ -30,8 +30,8 @@ import subprocess from typing import Optional, Tuple -from snapcraft.internal import errors, sources -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.internal import errors, sources +from snapcraft_legacy.plugins.v1 import PluginV1 _MINICONDA_CHECKSUMS = {"4.6.14": "md5/718259965f234088d785cad1fbd7de03"} diff --git a/snapcraft/plugins/v1/crystal.py b/snapcraft_legacy/plugins/v1/crystal.py similarity index 96% rename from snapcraft/plugins/v1/crystal.py rename to snapcraft_legacy/plugins/v1/crystal.py index 9b0c8736fd..2b765b9c22 100644 --- a/snapcraft/plugins/v1/crystal.py +++ b/snapcraft_legacy/plugins/v1/crystal.py @@ -35,9 +35,9 @@ import os import shutil -from snapcraft import file_utils -from snapcraft.internal import common, elf, errors -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import common, elf, errors +from snapcraft_legacy.plugins.v1 import PluginV1 _CRYSTAL_CHANNEL = "latest/stable" diff --git a/snapcraft/plugins/v1/dotnet.py b/snapcraft_legacy/plugins/v1/dotnet.py similarity index 97% rename from snapcraft/plugins/v1/dotnet.py rename to snapcraft_legacy/plugins/v1/dotnet.py index 41a294a953..0065518435 100644 --- a/snapcraft/plugins/v1/dotnet.py +++ b/snapcraft_legacy/plugins/v1/dotnet.py @@ -37,9 +37,9 @@ import urllib.request from typing import List -from snapcraft import formatting_utils, sources -from snapcraft.internal import errors -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import formatting_utils, sources +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import PluginV1 _DOTNET_RELEASE_METADATA_URL = "https://dotnetcli.blob.core.windows.net/dotnet/release-metadata/{version}/releases.json" # noqa _RUNTIME_DEFAULT = "2.0.9" diff --git a/snapcraft/plugins/v1/dump.py b/snapcraft_legacy/plugins/v1/dump.py similarity index 90% rename from snapcraft/plugins/v1/dump.py rename to snapcraft_legacy/plugins/v1/dump.py index dc4765a6b6..5bb97f423f 100644 --- a/snapcraft/plugins/v1/dump.py +++ b/snapcraft_legacy/plugins/v1/dump.py @@ -27,9 +27,9 @@ import os -import snapcraft -from snapcraft.internal import errors -from snapcraft.plugins.v1 import PluginV1 +import snapcraft_legacy +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import PluginV1 class DumpInvalidSymlinkError(errors.SnapcraftError): @@ -54,7 +54,7 @@ def enable_cross_compilation(self): def build(self): super().build() - snapcraft.file_utils.link_or_copy_tree( + snapcraft_legacy.file_utils.link_or_copy_tree( self.builddir, self.installdir, copy_function=lambda src, dst: _link_or_copy(src, dst, self.installdir), @@ -75,11 +75,11 @@ def _link_or_copy(source, destination, boundary): normalized = os.path.normpath(os.path.join(destination_dirname, link)) if os.path.isabs(link) or not normalized.startswith(boundary): # Only follow symlinks that are NOT pointing at libc (LP: #1658774) - if link not in snapcraft.repo.Repo.get_package_libraries("libc6"): + if link not in snapcraft_legacy.repo.Repo.get_package_libraries("libc6"): follow_symlinks = True try: - snapcraft.file_utils.link_or_copy( + snapcraft_legacy.file_utils.link_or_copy( source, destination, follow_symlinks=follow_symlinks ) except errors.SnapcraftCopyFileNotFoundError: diff --git a/snapcraft/plugins/v1/flutter.py b/snapcraft_legacy/plugins/v1/flutter.py similarity index 97% rename from snapcraft/plugins/v1/flutter.py rename to snapcraft_legacy/plugins/v1/flutter.py index 408c55fd4a..aa11ac8b4d 100644 --- a/snapcraft/plugins/v1/flutter.py +++ b/snapcraft_legacy/plugins/v1/flutter.py @@ -38,8 +38,8 @@ import subprocess from typing import Any, Dict, List -from snapcraft import file_utils -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import file_utils +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/go.py b/snapcraft_legacy/plugins/v1/go.py similarity index 98% rename from snapcraft/plugins/v1/go.py rename to snapcraft_legacy/plugins/v1/go.py index 67d6636767..e3ff1c6f13 100644 --- a/snapcraft/plugins/v1/go.py +++ b/snapcraft_legacy/plugins/v1/go.py @@ -67,12 +67,12 @@ from pkg_resources import parse_version -from snapcraft import common -from snapcraft.internal import elf, errors -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import common +from snapcraft_legacy.internal import elf, errors +from snapcraft_legacy.plugins.v1 import PluginV1 if TYPE_CHECKING: - from snapcraft.project import Project + from snapcraft_legacy.project import Project logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/godeps.py b/snapcraft_legacy/plugins/v1/godeps.py similarity index 98% rename from snapcraft/plugins/v1/godeps.py rename to snapcraft_legacy/plugins/v1/godeps.py index 96cb99a927..9f495c6196 100644 --- a/snapcraft/plugins/v1/godeps.py +++ b/snapcraft_legacy/plugins/v1/godeps.py @@ -58,8 +58,8 @@ import os import shutil -from snapcraft import common -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import common +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/gradle.py b/snapcraft_legacy/plugins/v1/gradle.py similarity index 98% rename from snapcraft/plugins/v1/gradle.py rename to snapcraft_legacy/plugins/v1/gradle.py index 7763a3220d..59ccbb5617 100644 --- a/snapcraft/plugins/v1/gradle.py +++ b/snapcraft_legacy/plugins/v1/gradle.py @@ -59,9 +59,9 @@ from glob import glob from typing import Sequence -from snapcraft import file_utils, formatting_utils -from snapcraft.internal import errors, sources -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import file_utils, formatting_utils +from snapcraft_legacy.internal import errors, sources +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/kbuild.py b/snapcraft_legacy/plugins/v1/kbuild.py similarity index 98% rename from snapcraft/plugins/v1/kbuild.py rename to snapcraft_legacy/plugins/v1/kbuild.py index 919c7274d8..4de12303da 100644 --- a/snapcraft/plugins/v1/kbuild.py +++ b/snapcraft_legacy/plugins/v1/kbuild.py @@ -17,7 +17,7 @@ """The kbuild plugin is used for building kbuild based projects as snapcraft parts. -This plugin is based on the snapcraft.BasePlugin and supports the properties +This plugin is based on the snapcraft_legacy.BasePlugin and supports the properties provided by that plus the following kbuild specific options with semantics as explained above: @@ -65,8 +65,8 @@ import re import subprocess -from snapcraft import file_utils -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import file_utils +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/kernel.py b/snapcraft_legacy/plugins/v1/kernel.py similarity index 98% rename from snapcraft/plugins/v1/kernel.py rename to snapcraft_legacy/plugins/v1/kernel.py index 03f58dbe4f..7efbdd089b 100644 --- a/snapcraft/plugins/v1/kernel.py +++ b/snapcraft_legacy/plugins/v1/kernel.py @@ -63,8 +63,8 @@ import subprocess import tempfile -import snapcraft -from snapcraft.plugins.v1 import kbuild +import snapcraft_legacy +from snapcraft_legacy.plugins.v1 import kbuild logger = logging.getLogger(__name__) @@ -273,7 +273,9 @@ def _unpack_generic_initrd(self): os.makedirs(initrd_unpacked_path) with tempfile.TemporaryDirectory() as temp_dir: - unsquashfs_path = snapcraft.file_utils.get_snap_tool_path("unsquashfs") + unsquashfs_path = snapcraft_legacy.file_utils.get_snap_tool_path( + "unsquashfs" + ) subprocess.check_call( [unsquashfs_path, self.os_snap, os.path.dirname(initrd_path)], cwd=temp_dir, @@ -507,7 +509,7 @@ def _do_check_initrd(self, builtin, modules): def pull(self): super().pull() - snapcraft.download( + snapcraft_legacy.download( "core", risk="stable", download_path=self.os_snap, diff --git a/snapcraft/plugins/v1/make.py b/snapcraft_legacy/plugins/v1/make.py similarity index 93% rename from snapcraft/plugins/v1/make.py rename to snapcraft_legacy/plugins/v1/make.py index 8921de29a0..f8e3ee4e7d 100644 --- a/snapcraft/plugins/v1/make.py +++ b/snapcraft_legacy/plugins/v1/make.py @@ -49,8 +49,8 @@ import os -import snapcraft.common -from snapcraft.plugins.v1 import PluginV1 +import snapcraft_legacy.common +from snapcraft_legacy.plugins.v1 import PluginV1 class MakePlugin(PluginV1): @@ -105,11 +105,13 @@ def make(self, env=None): source_path = os.path.join(self.builddir, artifact) destination_path = os.path.join(self.installdir, artifact) if os.path.isdir(source_path): - snapcraft.file_utils.link_or_copy_tree( + snapcraft_legacy.file_utils.link_or_copy_tree( source_path, destination_path ) else: - snapcraft.file_utils.link_or_copy(source_path, destination_path) + snapcraft_legacy.file_utils.link_or_copy( + source_path, destination_path + ) else: install_command = command + ["install"] + self.options.make_parameters if self.options.make_install_var: diff --git a/snapcraft/plugins/v1/maven.py b/snapcraft_legacy/plugins/v1/maven.py similarity index 98% rename from snapcraft/plugins/v1/maven.py rename to snapcraft_legacy/plugins/v1/maven.py index 970e4fc60a..0f7e871a85 100644 --- a/snapcraft/plugins/v1/maven.py +++ b/snapcraft_legacy/plugins/v1/maven.py @@ -61,9 +61,9 @@ from urllib.parse import urlparse from xml.etree import ElementTree -from snapcraft import file_utils, formatting_utils -from snapcraft.internal import errors, sources -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import file_utils, formatting_utils +from snapcraft_legacy.internal import errors, sources +from snapcraft_legacy.plugins.v1 import PluginV1 logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/meson.py b/snapcraft_legacy/plugins/v1/meson.py similarity index 97% rename from snapcraft/plugins/v1/meson.py rename to snapcraft_legacy/plugins/v1/meson.py index 1f8af8ed3b..9a88a408f3 100644 --- a/snapcraft/plugins/v1/meson.py +++ b/snapcraft_legacy/plugins/v1/meson.py @@ -37,8 +37,8 @@ import os import subprocess -from snapcraft.internal import errors -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import PluginV1 class MesonPlugin(PluginV1): diff --git a/snapcraft/plugins/v1/nil.py b/snapcraft_legacy/plugins/v1/nil.py similarity index 95% rename from snapcraft/plugins/v1/nil.py rename to snapcraft_legacy/plugins/v1/nil.py index 50aadfa59c..f963af71b8 100644 --- a/snapcraft/plugins/v1/nil.py +++ b/snapcraft_legacy/plugins/v1/nil.py @@ -20,7 +20,7 @@ included by Snapcraft, e.g. stage-packages. """ -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.plugins.v1 import PluginV1 class NilPlugin(PluginV1): diff --git a/snapcraft/plugins/v1/nodejs.py b/snapcraft_legacy/plugins/v1/nodejs.py similarity index 98% rename from snapcraft/plugins/v1/nodejs.py rename to snapcraft_legacy/plugins/v1/nodejs.py index 07a8b88edf..3b1c232d84 100644 --- a/snapcraft/plugins/v1/nodejs.py +++ b/snapcraft_legacy/plugins/v1/nodejs.py @@ -49,10 +49,10 @@ import subprocess import sys -from snapcraft import sources -from snapcraft.file_utils import link_or_copy, link_or_copy_tree -from snapcraft.internal import errors -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import sources +from snapcraft_legacy.file_utils import link_or_copy, link_or_copy_tree +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import PluginV1 _NODEJS_BASE = "node-v{version}-linux-{arch}" _NODEJS_VERSION = "8.12.0" diff --git a/snapcraft/plugins/v1/plainbox_provider.py b/snapcraft_legacy/plugins/v1/plainbox_provider.py similarity index 97% rename from snapcraft/plugins/v1/plainbox_provider.py rename to snapcraft_legacy/plugins/v1/plainbox_provider.py index 18e9e088be..90dbad37e2 100644 --- a/snapcraft/plugins/v1/plainbox_provider.py +++ b/snapcraft_legacy/plugins/v1/plainbox_provider.py @@ -31,8 +31,8 @@ import os -from snapcraft.internal import mangling -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.internal import mangling +from snapcraft_legacy.plugins.v1 import PluginV1 class PlainboxProviderPlugin(PluginV1): diff --git a/snapcraft/plugins/v1/python.py b/snapcraft_legacy/plugins/v1/python.py similarity index 98% rename from snapcraft/plugins/v1/python.py rename to snapcraft_legacy/plugins/v1/python.py index df7b635a81..69df13d07a 100644 --- a/snapcraft/plugins/v1/python.py +++ b/snapcraft_legacy/plugins/v1/python.py @@ -63,10 +63,10 @@ import requests -from snapcraft.common import isurl -from snapcraft.internal import errors, mangling -from snapcraft.internal.errors import SnapcraftPluginCommandError -from snapcraft.plugins.v1 import PluginV1, _python +from snapcraft_legacy.common import isurl +from snapcraft_legacy.internal import errors, mangling +from snapcraft_legacy.internal.errors import SnapcraftPluginCommandError +from snapcraft_legacy.plugins.v1 import PluginV1, _python logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/qmake.py b/snapcraft_legacy/plugins/v1/qmake.py similarity index 98% rename from snapcraft/plugins/v1/qmake.py rename to snapcraft_legacy/plugins/v1/qmake.py index 1c1f52a60f..51f9bffee2 100644 --- a/snapcraft/plugins/v1/qmake.py +++ b/snapcraft_legacy/plugins/v1/qmake.py @@ -37,8 +37,8 @@ import os -from snapcraft import common -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import common +from snapcraft_legacy.plugins.v1 import PluginV1 class QmakePlugin(PluginV1): diff --git a/snapcraft/plugins/v1/ruby.py b/snapcraft_legacy/plugins/v1/ruby.py similarity index 97% rename from snapcraft/plugins/v1/ruby.py rename to snapcraft_legacy/plugins/v1/ruby.py index 537b3db43e..21e0dd37ed 100644 --- a/snapcraft/plugins/v1/ruby.py +++ b/snapcraft_legacy/plugins/v1/ruby.py @@ -36,10 +36,10 @@ import os import re -from snapcraft import file_utils -from snapcraft.internal import errors -from snapcraft.plugins.v1 import PluginV1 -from snapcraft.sources import Tar +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import PluginV1 +from snapcraft_legacy.sources import Tar logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/rust.py b/snapcraft_legacy/plugins/v1/rust.py similarity index 98% rename from snapcraft/plugins/v1/rust.py rename to snapcraft_legacy/plugins/v1/rust.py index 8bd131dc03..0be0f20ed7 100644 --- a/snapcraft/plugins/v1/rust.py +++ b/snapcraft_legacy/plugins/v1/rust.py @@ -51,9 +51,9 @@ import toml -from snapcraft import file_utils, shell_utils, sources -from snapcraft.internal import errors -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy import file_utils, shell_utils, sources +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import PluginV1 _RUSTUP = "https://sh.rustup.rs/" logger = logging.getLogger(__name__) diff --git a/snapcraft/plugins/v1/scons.py b/snapcraft_legacy/plugins/v1/scons.py similarity index 97% rename from snapcraft/plugins/v1/scons.py rename to snapcraft_legacy/plugins/v1/scons.py index 9b578609af..7cbfe2a55d 100644 --- a/snapcraft/plugins/v1/scons.py +++ b/snapcraft_legacy/plugins/v1/scons.py @@ -31,7 +31,7 @@ import os -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.plugins.v1 import PluginV1 class SconsPlugin(PluginV1): diff --git a/snapcraft/plugins/v1/waf.py b/snapcraft_legacy/plugins/v1/waf.py similarity index 98% rename from snapcraft/plugins/v1/waf.py rename to snapcraft_legacy/plugins/v1/waf.py index bab4121d47..4303a34d3e 100644 --- a/snapcraft/plugins/v1/waf.py +++ b/snapcraft_legacy/plugins/v1/waf.py @@ -32,7 +32,7 @@ ./waf --help """ -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.plugins.v1 import PluginV1 class WafPlugin(PluginV1): diff --git a/snapcraft/plugins/v2/__init__.py b/snapcraft_legacy/plugins/v2/__init__.py similarity index 100% rename from snapcraft/plugins/v2/__init__.py rename to snapcraft_legacy/plugins/v2/__init__.py diff --git a/snapcraft/plugins/v2/_plugin.py b/snapcraft_legacy/plugins/v2/_plugin.py similarity index 100% rename from snapcraft/plugins/v2/_plugin.py rename to snapcraft_legacy/plugins/v2/_plugin.py diff --git a/snapcraft/plugins/v2/_ros.py b/snapcraft_legacy/plugins/v2/_ros.py similarity index 97% rename from snapcraft/plugins/v2/_ros.py rename to snapcraft_legacy/plugins/v2/_ros.py index f27c7f1e4c..ed32e173a4 100644 --- a/snapcraft/plugins/v2/_ros.py +++ b/snapcraft_legacy/plugins/v2/_ros.py @@ -24,9 +24,9 @@ import click from catkin_pkg import packages as catkin_packages -from snapcraft.internal.repo import Repo -from snapcraft.plugins.v1._ros.rosdep import _parse_rosdep_resolve_dependencies -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.internal.repo import Repo +from snapcraft_legacy.plugins.v1._ros.rosdep import _parse_rosdep_resolve_dependencies +from snapcraft_legacy.plugins.v2 import PluginV2 class RosPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/autotools.py b/snapcraft_legacy/plugins/v2/autotools.py similarity index 98% rename from snapcraft/plugins/v2/autotools.py rename to snapcraft_legacy/plugins/v2/autotools.py index 829d7f6a5b..59c6bf67b0 100644 --- a/snapcraft/plugins/v2/autotools.py +++ b/snapcraft_legacy/plugins/v2/autotools.py @@ -36,7 +36,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class AutotoolsPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/catkin.py b/snapcraft_legacy/plugins/v2/catkin.py similarity index 99% rename from snapcraft/plugins/v2/catkin.py rename to snapcraft_legacy/plugins/v2/catkin.py index 85a94abb6f..c8d4038e77 100644 --- a/snapcraft/plugins/v2/catkin.py +++ b/snapcraft_legacy/plugins/v2/catkin.py @@ -38,7 +38,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import _ros +from snapcraft_legacy.plugins.v2 import _ros class CatkinPlugin(_ros.RosPlugin): diff --git a/snapcraft/plugins/v2/catkin_tools.py b/snapcraft_legacy/plugins/v2/catkin_tools.py similarity index 99% rename from snapcraft/plugins/v2/catkin_tools.py rename to snapcraft_legacy/plugins/v2/catkin_tools.py index 2565ec023a..23c2e9bb1f 100644 --- a/snapcraft/plugins/v2/catkin_tools.py +++ b/snapcraft_legacy/plugins/v2/catkin_tools.py @@ -33,7 +33,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import _ros +from snapcraft_legacy.plugins.v2 import _ros class CatkinToolsPlugin(_ros.RosPlugin): diff --git a/snapcraft/plugins/v2/cmake.py b/snapcraft_legacy/plugins/v2/cmake.py similarity index 98% rename from snapcraft/plugins/v2/cmake.py rename to snapcraft_legacy/plugins/v2/cmake.py index 3fa1bee649..01a0b9df02 100644 --- a/snapcraft/plugins/v2/cmake.py +++ b/snapcraft_legacy/plugins/v2/cmake.py @@ -42,7 +42,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class CMakePlugin(PluginV2): diff --git a/snapcraft/plugins/v2/colcon.py b/snapcraft_legacy/plugins/v2/colcon.py similarity index 99% rename from snapcraft/plugins/v2/colcon.py rename to snapcraft_legacy/plugins/v2/colcon.py index b13cd35d5d..5e7df88594 100644 --- a/snapcraft/plugins/v2/colcon.py +++ b/snapcraft_legacy/plugins/v2/colcon.py @@ -56,7 +56,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import _ros +from snapcraft_legacy.plugins.v2 import _ros class ColconPlugin(_ros.RosPlugin): diff --git a/snapcraft/plugins/v2/conda.py b/snapcraft_legacy/plugins/v2/conda.py similarity index 97% rename from snapcraft/plugins/v2/conda.py rename to snapcraft_legacy/plugins/v2/conda.py index 42415f2859..9d842bfe23 100644 --- a/snapcraft/plugins/v2/conda.py +++ b/snapcraft_legacy/plugins/v2/conda.py @@ -40,8 +40,8 @@ from textwrap import dedent from typing import Any, Dict, List, Set -from snapcraft.internal.errors import SnapcraftException -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.internal.errors import SnapcraftException +from snapcraft_legacy.plugins.v2 import PluginV2 _MINICONDA_ARCH_FROM_SNAP_ARCH = { diff --git a/snapcraft/plugins/v2/crystal.py b/snapcraft_legacy/plugins/v2/crystal.py similarity index 96% rename from snapcraft/plugins/v2/crystal.py rename to snapcraft_legacy/plugins/v2/crystal.py index d721c987ff..60074e92e9 100644 --- a/snapcraft/plugins/v2/crystal.py +++ b/snapcraft_legacy/plugins/v2/crystal.py @@ -39,10 +39,10 @@ import click -from snapcraft import file_utils -from snapcraft.internal import common, elf, errors +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import common, elf, errors -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 _CRYSTAL_CHANNEL = "latest/stable" @@ -164,8 +164,7 @@ def stage_runtime_dependencies( for elf_file in elf_files: shutil.copy2( - elf_file.path, - os.path.join(install_path, os.path.basename(elf_file.path)), + elf_file.path, os.path.join(install_path, os.path.basename(elf_file.path)), ) elf_dependencies_path = elf_file.load_dependencies( diff --git a/snapcraft/plugins/v2/dump.py b/snapcraft_legacy/plugins/v2/dump.py similarity index 97% rename from snapcraft/plugins/v2/dump.py rename to snapcraft_legacy/plugins/v2/dump.py index 5bce7f8230..534411f5d5 100644 --- a/snapcraft/plugins/v2/dump.py +++ b/snapcraft_legacy/plugins/v2/dump.py @@ -27,7 +27,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class DumpPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/go.py b/snapcraft_legacy/plugins/v2/go.py similarity index 98% rename from snapcraft/plugins/v2/go.py rename to snapcraft_legacy/plugins/v2/go.py index 552077ebec..88749f1972 100644 --- a/snapcraft/plugins/v2/go.py +++ b/snapcraft_legacy/plugins/v2/go.py @@ -33,7 +33,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class GoPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/make.py b/snapcraft_legacy/plugins/v2/make.py similarity index 98% rename from snapcraft/plugins/v2/make.py rename to snapcraft_legacy/plugins/v2/make.py index 6f8a6d5b53..164b9b728d 100644 --- a/snapcraft/plugins/v2/make.py +++ b/snapcraft_legacy/plugins/v2/make.py @@ -35,7 +35,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class MakePlugin(PluginV2): diff --git a/snapcraft/plugins/v2/meson.py b/snapcraft_legacy/plugins/v2/meson.py similarity index 98% rename from snapcraft/plugins/v2/meson.py rename to snapcraft_legacy/plugins/v2/meson.py index 8c724771dd..531aa2f803 100644 --- a/snapcraft/plugins/v2/meson.py +++ b/snapcraft_legacy/plugins/v2/meson.py @@ -35,7 +35,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class MesonPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/nil.py b/snapcraft_legacy/plugins/v2/nil.py similarity index 96% rename from snapcraft/plugins/v2/nil.py rename to snapcraft_legacy/plugins/v2/nil.py index fb5b8e9dcb..996695e3b4 100644 --- a/snapcraft/plugins/v2/nil.py +++ b/snapcraft_legacy/plugins/v2/nil.py @@ -22,7 +22,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class NilPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/npm.py b/snapcraft_legacy/plugins/v2/npm.py similarity index 98% rename from snapcraft/plugins/v2/npm.py rename to snapcraft_legacy/plugins/v2/npm.py index 7766afbe84..d6ce2d2ed3 100644 --- a/snapcraft/plugins/v2/npm.py +++ b/snapcraft_legacy/plugins/v2/npm.py @@ -36,7 +36,7 @@ from textwrap import dedent from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 _NODE_ARCH_FROM_SNAP_ARCH = { "i386": "x86", diff --git a/snapcraft/plugins/v2/python.py b/snapcraft_legacy/plugins/v2/python.py similarity index 99% rename from snapcraft/plugins/v2/python.py rename to snapcraft_legacy/plugins/v2/python.py index 0589bca0a3..2eb4e21efd 100644 --- a/snapcraft/plugins/v2/python.py +++ b/snapcraft_legacy/plugins/v2/python.py @@ -67,7 +67,7 @@ from textwrap import dedent from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class PythonPlugin(PluginV2): diff --git a/snapcraft/plugins/v2/qmake.py b/snapcraft_legacy/plugins/v2/qmake.py similarity index 98% rename from snapcraft/plugins/v2/qmake.py rename to snapcraft_legacy/plugins/v2/qmake.py index 9da6feebd0..714f36fd39 100644 --- a/snapcraft/plugins/v2/qmake.py +++ b/snapcraft_legacy/plugins/v2/qmake.py @@ -36,7 +36,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class QMakePlugin(PluginV2): diff --git a/snapcraft/plugins/v2/rust.py b/snapcraft_legacy/plugins/v2/rust.py similarity index 98% rename from snapcraft/plugins/v2/rust.py rename to snapcraft_legacy/plugins/v2/rust.py index 7f31a8628a..afb1c17e0e 100644 --- a/snapcraft/plugins/v2/rust.py +++ b/snapcraft_legacy/plugins/v2/rust.py @@ -37,7 +37,7 @@ from textwrap import dedent from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class RustPlugin(PluginV2): diff --git a/snapcraft/project/__init__.py b/snapcraft_legacy/project/__init__.py similarity index 100% rename from snapcraft/project/__init__.py rename to snapcraft_legacy/project/__init__.py diff --git a/snapcraft/project/_get_snapcraft.py b/snapcraft_legacy/project/_get_snapcraft.py similarity index 100% rename from snapcraft/project/_get_snapcraft.py rename to snapcraft_legacy/project/_get_snapcraft.py diff --git a/snapcraft/project/_project.py b/snapcraft_legacy/project/_project.py similarity index 97% rename from snapcraft/project/_project.py rename to snapcraft_legacy/project/_project.py index 038bfea466..e479cf1ff7 100644 --- a/snapcraft/project/_project.py +++ b/snapcraft_legacy/project/_project.py @@ -20,8 +20,8 @@ from pathlib import Path from typing import List, Set -from snapcraft.internal.deprecations import handle_deprecation_notice -from snapcraft.internal.meta.snap import Snap +from snapcraft_legacy.internal.deprecations import handle_deprecation_notice +from snapcraft_legacy.internal.meta.snap import Snap from ._project_info import ProjectInfo # noqa: F401 from ._project_options import ProjectOptions diff --git a/snapcraft/project/_project_info.py b/snapcraft_legacy/project/_project_info.py similarity index 93% rename from snapcraft/project/_project_info.py rename to snapcraft_legacy/project/_project_info.py index b525eba369..e509ccd4ee 100644 --- a/snapcraft/project/_project_info.py +++ b/snapcraft_legacy/project/_project_info.py @@ -16,8 +16,8 @@ from copy import deepcopy -import snapcraft.yaml_utils.errors -from snapcraft import yaml_utils +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy import yaml_utils from . import _schema @@ -32,7 +32,7 @@ def __init__(self, *, snapcraft_yaml_file_path) -> None: try: self.name = self.__raw_snapcraft["name"] except KeyError as key_error: - raise snapcraft.yaml_utils.errors.YamlValidationError( + raise snapcraft_legacy.yaml_utils.errors.YamlValidationError( "'name' is a required property in {!r}".format(snapcraft_yaml_file_path) ) from key_error self.version = self.__raw_snapcraft.get("version") diff --git a/snapcraft/project/_project_options.py b/snapcraft_legacy/project/_project_options.py similarity index 97% rename from snapcraft/project/_project_options.py rename to snapcraft_legacy/project/_project_options.py index 9ab9971a05..eaee204c05 100644 --- a/snapcraft/project/_project_options.py +++ b/snapcraft_legacy/project/_project_options.py @@ -21,8 +21,8 @@ import sys from typing import Set -from snapcraft import file_utils -from snapcraft.internal import common, errors, os_release +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import common, errors, os_release logger = logging.getLogger(__name__) @@ -309,9 +309,9 @@ def get_core_dynamic_linker(self, base: str, expand: bool = True) -> str: projects architecture. :return: the absolute path to the linker :rtype: str - :raises snapcraft.internal.errors.SnapcraftMissingLinkerInBaseError: + :raises snapcraft_legacy.internal.errors.SnapcraftMissingLinkerInBaseError: if the linker cannot be found in the base. - :raises snapcraft.internal.errors.SnapcraftEnvironmentError: + :raises snapcraft_legacy.internal.errors.SnapcraftEnvironmentError: if a loop is found while resolving the real path to the linker. """ core_path = common.get_installed_snap_path(base) diff --git a/snapcraft/project/_sanity_checks.py b/snapcraft_legacy/project/_sanity_checks.py similarity index 96% rename from snapcraft/project/_sanity_checks.py rename to snapcraft_legacy/project/_sanity_checks.py index 10d8e725f3..d83d804b5e 100644 --- a/snapcraft/project/_sanity_checks.py +++ b/snapcraft_legacy/project/_sanity_checks.py @@ -18,8 +18,8 @@ import os import re -from snapcraft.internal.errors import SnapcraftEnvironmentError -from snapcraft.project import Project, errors +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError +from snapcraft_legacy.project import Project, errors logger = logging.getLogger(__name__) diff --git a/snapcraft/project/_schema.py b/snapcraft_legacy/project/_schema.py similarity index 89% rename from snapcraft/project/_schema.py rename to snapcraft_legacy/project/_schema.py index d8e26ef0f8..4801291f11 100644 --- a/snapcraft/project/_schema.py +++ b/snapcraft_legacy/project/_schema.py @@ -20,8 +20,8 @@ import jsonschema -import snapcraft.yaml_utils.errors -from snapcraft.internal import common +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.internal import common class Validator: @@ -58,7 +58,7 @@ def _load_schema(self): with open(schema_file) as fp: self._schema = json.load(fp) except FileNotFoundError: - raise snapcraft.yaml_utils.errors.YamlValidationError( + raise snapcraft_legacy.yaml_utils.errors.YamlValidationError( "snapcraft validation file is missing from installation path" ) @@ -69,6 +69,6 @@ def validate(self, *, source="snapcraft.yaml"): self._snapcraft, self._schema, format_checker=format_check ) except jsonschema.ValidationError as e: - raise snapcraft.yaml_utils.errors.YamlValidationError.from_validation_error( + raise snapcraft_legacy.yaml_utils.errors.YamlValidationError.from_validation_error( e, source=source ) diff --git a/snapcraft/project/errors.py b/snapcraft_legacy/project/errors.py similarity index 97% rename from snapcraft/project/errors.py rename to snapcraft_legacy/project/errors.py index fe87633be0..0e98f73ba9 100644 --- a/snapcraft/project/errors.py +++ b/snapcraft_legacy/project/errors.py @@ -16,7 +16,7 @@ from typing import Optional -from snapcraft.internal.errors import SnapcraftError, SnapcraftException +from snapcraft_legacy.internal.errors import SnapcraftError, SnapcraftException # dict of jsonschema validator -> cause pairs. Wish jsonschema just gave us # better messages. diff --git a/snapcraft/scripts/__init__.py b/snapcraft_legacy/scripts/__init__.py similarity index 100% rename from snapcraft/scripts/__init__.py rename to snapcraft_legacy/scripts/__init__.py diff --git a/snapcraft/scripts/generate_reference.py b/snapcraft_legacy/scripts/generate_reference.py similarity index 100% rename from snapcraft/scripts/generate_reference.py rename to snapcraft_legacy/scripts/generate_reference.py diff --git a/snapcraft/shell_utils.py b/snapcraft_legacy/shell_utils.py similarity index 96% rename from snapcraft/shell_utils.py rename to snapcraft_legacy/shell_utils.py index 515e97ebb4..3fc7ea0016 100644 --- a/snapcraft/shell_utils.py +++ b/snapcraft_legacy/shell_utils.py @@ -19,7 +19,7 @@ import tempfile -from snapcraft.internal import common +from snapcraft_legacy.internal import common def which(command, **kwargs): diff --git a/snapcraft/sources.py b/snapcraft_legacy/sources.py similarity index 50% rename from snapcraft/sources.py rename to snapcraft_legacy/sources.py index 7704dafe2e..502c9e519f 100644 --- a/snapcraft/sources.py +++ b/snapcraft_legacy/sources.py @@ -17,14 +17,14 @@ import sys as _sys if _sys.platform == "linux": - from snapcraft.internal.sources import Bazaar # noqa - from snapcraft.internal.sources import Deb # noqa - from snapcraft.internal.sources import Git # noqa - from snapcraft.internal.sources import Local # noqa - from snapcraft.internal.sources import Mercurial # noqa - from snapcraft.internal.sources import Rpm # noqa - from snapcraft.internal.sources import Script # noqa - from snapcraft.internal.sources import Subversion # noqa - from snapcraft.internal.sources import Tar # noqa - from snapcraft.internal.sources import Zip # noqa - from snapcraft.internal.sources import get # noqa + from snapcraft_legacy.internal.sources import Bazaar # noqa + from snapcraft_legacy.internal.sources import Deb # noqa + from snapcraft_legacy.internal.sources import Git # noqa + from snapcraft_legacy.internal.sources import Local # noqa + from snapcraft_legacy.internal.sources import Mercurial # noqa + from snapcraft_legacy.internal.sources import Rpm # noqa + from snapcraft_legacy.internal.sources import Script # noqa + from snapcraft_legacy.internal.sources import Subversion # noqa + from snapcraft_legacy.internal.sources import Tar # noqa + from snapcraft_legacy.internal.sources import Zip # noqa + from snapcraft_legacy.internal.sources import get # noqa diff --git a/snapcraft/storeapi/__init__.py b/snapcraft_legacy/storeapi/__init__.py similarity index 64% rename from snapcraft/storeapi/__init__.py rename to snapcraft_legacy/storeapi/__init__.py index 19156d408b..9b99cfed50 100644 --- a/snapcraft/storeapi/__init__.py +++ b/snapcraft_legacy/storeapi/__init__.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2016-2017, 2020-2021 Canonical Ltd +# Copyright 2016-2017, 2020-2022 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -16,12 +16,22 @@ import logging -from . import errors # noqa: F401 isort:skip -from . import channels # noqa: F401 isort:skip -from . import status # noqa: F401 isort:skip -from . import http_clients # noqa: F401 isort: skip +from . import errors # isort:skip +from . import channels # isort:skip +from . import constants # isort:skip +from . import status # isort:skip logger = logging.getLogger(__name__) -from ._store_client import StoreClient # noqa +from ._store_client import StoreClient +from ._snap_api import SnapAPI + +__all__ = [ + "errors", + "channels", + "constants", + "status", + "SnapAPI", + "StoreClient", +] diff --git a/snapcraft_legacy/storeapi/_dashboard_api.py b/snapcraft_legacy/storeapi/_dashboard_api.py new file mode 100644 index 0000000000..ad544e31e1 --- /dev/null +++ b/snapcraft_legacy/storeapi/_dashboard_api.py @@ -0,0 +1,413 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2016-2021 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import logging +from typing import Any, Dict, List, Optional +from urllib.parse import urlencode, urljoin + +import craft_store +import requests +from simplejson.scanner import JSONDecodeError + +from . import _metadata, errors, metrics +from ._requests import Requests +from .v2 import releases, validation_sets, whoami + +logger = logging.getLogger(__name__) + + +class DashboardAPI(Requests): + """The Dashboard API is used to publish and manage snaps. + + This is an interface to query that API which is documented + at https://dashboard.snapcraft.io/docs/. + """ + + def __init__(self, auth_client: craft_store.BaseClient) -> None: + super().__init__() + + self._auth_client = auth_client + + def _request(self, method: str, urlpath: str, **kwargs) -> requests.Response: + url = urljoin(self._auth_client._base_url, urlpath) + response = self._auth_client.request(method, url, **kwargs) + logger.debug("Call to %s returned: %s", url, response.text) + return response + + def verify_acl(self): + if not isinstance(self._auth_client, craft_store.UbuntuOneStoreClient): + raise NotImplementedError("Only supports UbuntuOneAuthClient.") + + try: + response = self.post( + "/dev/api/acl/verify/", + json={ + "auth_data": { + "authorization": self._auth_client._auth.get_credentials() + } + }, + headers={"Accept": "application/json"}, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreAccountInformationError( + store_error.response + ) from store_error + + return response.json() + + def get_account_information(self) -> Dict[str, Any]: + try: + response = self.get( + "/dev/api/account", headers={"Accept": "application/json"} + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreAccountInformationError( + store_error.response + ) from store_error + + return response.json() + + def register_key(self, account_key_request): + data = {"account_key_request": account_key_request} + try: + self.post( + "/dev/api/account/account-key", + json=data, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreKeyRegistrationError( + store_error.response + ) from store_error + + def register( + self, snap_name: str, *, is_private: bool, series: str, store_id: Optional[str] + ) -> None: + data = dict(snap_name=snap_name, is_private=is_private, series=series) + if store_id is not None: + data["store"] = store_id + try: + self.post( + "/dev/api/register-name/", + json=data, + headers={"Content-Type": "application/json"}, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreRegistrationError( + snap_name, store_error.response + ) from store_error + + def snap_upload_precheck(self, snap_name) -> None: + data = {"name": snap_name, "dry_run": True} + try: + self.post( + "/dev/api/snap-push/", + json=data, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreUploadError( + snap_name, store_error.response + ) from store_error + + def upload_metadata(self, snap_id, snap_name, metadata, force): + """Upload the metadata to SCA.""" + metadata_handler = _metadata.StoreMetadataHandler( + request_method=self._request, + snap_id=snap_id, + snap_name=snap_name, + ) + metadata_handler.upload(metadata, force) + + def upload_binary_metadata(self, snap_id, snap_name, metadata, force): + """Upload the binary metadata to SCA.""" + metadata_handler = _metadata.StoreMetadataHandler( + request_method=self._request, + snap_id=snap_id, + snap_name=snap_name, + ) + metadata_handler.upload_binary(metadata, force) + + def snap_release( + self, + snap_name, + revision, + channels, + delta_format=None, + progressive_percentage: Optional[int] = None, + ): + data = {"name": snap_name, "revision": str(revision), "channels": channels} + if delta_format: + data["delta_format"] = delta_format + if progressive_percentage is not None: + data["progressive"] = { + "percentage": progressive_percentage, + "paused": False, + } + try: + response = self.post( + "/dev/api/snap-release/", + json=data, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreReleaseError( + data["name"], store_error.response + ) from store_error + + response_json = response.json() + + return response_json + + def push_assertion(self, snap_id, assertion, endpoint, force): + if endpoint == "validations": + data = {"assertion": assertion.decode("utf-8")} + elif endpoint == "developers": + data = {"snap_developer": assertion.decode("utf-8")} + else: + raise RuntimeError("No valid endpoint") + + url = "/dev/api/snaps/{}/{}".format(snap_id, endpoint) + + # For `snap-developer`, revoking developers will require their uploads + # to be invalidated. + if force: + url = url + "?ignore_revoked_uploads" + + try: + response = self.put( + url, + json=data, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as craft_error: + raise errors.StoreValidationError( + snap_id, craft_error.response + ) from craft_error + + try: + response_json = response.json() + except JSONDecodeError: + message = ( + "Invalid response from the server when pushing validations: {} {}" + ).format(response.status_code, response) + logger.debug(message) + raise errors.StoreValidationError( + snap_id, response, message="Invalid response from the server" + ) + + return response_json + + def get_assertion(self, snap_id, endpoint, params=None): + try: + response = self.get( + f"/dev/api/snaps/{snap_id}/{endpoint}", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + params=params, + ) + except craft_store.errors.StoreServerError as craft_error: + raise errors.StoreValidationError( + snap_id, craft_error.response + ) from craft_error + + try: + response_json = response.json() + except JSONDecodeError: + message = "Invalid response from the server when getting {}: {} {}".format( + endpoint, response.status_code, response + ) + logger.debug(message) + raise errors.StoreValidationError( + snap_id, response, message="Invalid response from the server" + ) + + return response_json + + def push_snap_build(self, snap_id, snap_build): + url = f"/dev/api/snaps/{snap_id}/builds" + data = json.dumps({"assertion": snap_build}) + headers = { + "Content-Type": "application/json", + } + try: + self.post(url, data=data, headers=headers) + except craft_store.errors.StoreServerError as craft_error: + raise errors.StoreSnapBuildError(craft_error.response) from craft_error + + def snap_status(self, snap_id, series, arch): + qs = {} + if series: + qs["series"] = series + if arch: + qs["architecture"] = arch + url = "/dev/api/snaps/" + snap_id + "/state" + if qs: + url += "?" + urlencode(qs) + try: + response = self.get( + url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as craft_error: + raise errors.StoreSnapStatusError( + craft_error.response, snap_id, series, arch + ) from craft_error + + response_json = response.json() + + return response_json + + def sign_developer_agreement(self, latest_tos_accepted=False): + data = {"latest_tos_accepted": latest_tos_accepted} + try: + response = self.post( + "/dev/api/agreement/", + json=data, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.DeveloperAgreementSignError( + store_error.response + ) from store_error + + return response.json() + + def get_metrics( + self, filters: List[metrics.MetricsFilter], snap_name: str + ) -> metrics.MetricsResults: + url = "/dev/api/snaps/metrics" + data = {"filters": [f.marshal() for f in filters]} + headers = {"Content-Type": "application/json", "Accept": "application/json"} + + try: + response = self.post(url, json=data, headers=headers) + + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreMetricsError( + filters=filters, response=store_error.response, snap_name=snap_name + ) from store_error + + try: + results = response.json() + return metrics.MetricsResults.unmarshal(results) + except ValueError as error: + raise errors.StoreMetricsUnmarshalError( + filters=filters, snap_name=snap_name, response=response + ) from error + + def get_snap_releases(self, *, snap_name: str) -> releases.Releases: + try: + response = self.get( + f"/api/v2/snaps/{snap_name}/releases", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreSnapChannelMapError(snap_name=snap_name) from store_error + + return releases.Releases.unmarshal(response.json()) + + def whoami(self) -> whoami.WhoAmI: + try: + response = self.get( + "/api/v2/tokens/whoami", + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.GeneralStoreError( + message="whoami failed.", response=store_error.response + ) from store_error + + return whoami.WhoAmI.unmarshal(response.json()) + + def post_validation_sets_build_assertion( + self, validation_sets_data: Dict[str, Any] + ) -> validation_sets.BuildAssertion: + try: + response = self.post( + "/api/v2/validation-sets/build-assertion", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + json=validation_sets_data, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreValidationSetsError(store_error.response) from store_error + + return validation_sets.BuildAssertion.unmarshal(response.json()) + + def post_validation_sets( + self, signed_validation_sets: bytes + ) -> validation_sets.ValidationSets: + try: + response = self.post( + "/api/v2/validation-sets", + headers={ + "Accept": "application/json", + "Content-Type": "application/x.ubuntu.assertion", + }, + data=signed_validation_sets, + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreValidationSetsError(store_error.response) from store_error + + return validation_sets.ValidationSets.unmarshal(response.json()) + + def get_validation_sets( + self, *, name: Optional[str], sequence: Optional[str] + ) -> validation_sets.ValidationSets: + url = "/api/v2/validation-sets" + if name is not None: + url += "/" + name + params = dict() + if sequence is not None: + params["sequence"] = sequence + try: + response = self.get( + url, headers={"Accept": "application/json"}, params=params + ) + except craft_store.errors.StoreServerError as store_error: + raise errors.StoreValidationSetsError(store_error.response) from store_error + + return validation_sets.ValidationSets.unmarshal(response.json()) diff --git a/snapcraft/storeapi/_metadata.py b/snapcraft_legacy/storeapi/_metadata.py similarity index 85% rename from snapcraft/storeapi/_metadata.py rename to snapcraft_legacy/storeapi/_metadata.py index 8bb1bd109a..a15b8ff2ae 100644 --- a/snapcraft/storeapi/_metadata.py +++ b/snapcraft_legacy/storeapi/_metadata.py @@ -18,7 +18,9 @@ import json import os -from snapcraft.storeapi.errors import StoreMetadataError +import craft_store + +from snapcraft_legacy.storeapi.errors import StoreMetadataError def _media_hash(media_file): @@ -43,12 +45,12 @@ def upload(self, metadata, force): "Accept": "application/json", } method = "PUT" if force else "POST" - response = self._request( - method, url, data=json.dumps(metadata), headers=headers - ) - - if not response.ok: - raise StoreMetadataError(self.snap_name, response, metadata) + try: + self._request(method, url, json=metadata, headers=headers) + except craft_store.errors.StoreServerError as store_error: + raise StoreMetadataError( + self.snap_name, store_error.response, metadata + ) from store_error def _current_binary_metadata(self): """Get current icons and screenshots as set in the store.""" @@ -119,8 +121,11 @@ def upload_binary(self, metadata, force): "Accept": "application/json", } method = "PUT" if force else "POST" - response = self._request(method, url, data=data, files=files, headers=headers) - if not response.ok: + try: + self._request(method, url, data=data, files=files, headers=headers) + except craft_store.errors.StoreServerError as store_error: icon = metadata.get("icon") icon_name = os.path.basename(icon.name) if icon else None - raise StoreMetadataError(self.snap_name, response, {"icon": icon_name}) + raise StoreMetadataError( + self.snap_name, store_error.response, {"icon": icon_name} + ) from store_error diff --git a/snapcraft/storeapi/_requests.py b/snapcraft_legacy/storeapi/_requests.py similarity index 100% rename from snapcraft/storeapi/_requests.py rename to snapcraft_legacy/storeapi/_requests.py diff --git a/snapcraft/storeapi/_snap_api.py b/snapcraft_legacy/storeapi/_snap_api.py similarity index 78% rename from snapcraft/storeapi/_snap_api.py rename to snapcraft_legacy/storeapi/_snap_api.py index a405946b99..e3e9ac64ef 100644 --- a/snapcraft/storeapi/_snap_api.py +++ b/snapcraft_legacy/storeapi/_snap_api.py @@ -16,12 +16,13 @@ import logging import os -from typing import Dict +from typing import Dict, Optional from urllib.parse import urljoin +import craft_store import requests -from . import constants, errors +from . import agent, constants, errors from ._requests import Requests from .info import SnapInfo @@ -36,7 +37,9 @@ class SnapAPI(Requests): at http://api.snapcraft.io/docs/. """ - def __init__(self, client): + def __init__(self, client: Optional[craft_store.HTTPClient] = None): + if client is None: + client = craft_store.HTTPClient(user_agent=agent.get_user_agent()) self._client = client self._root_url = os.environ.get("STORE_API_URL", constants.STORE_API_URL) @@ -90,13 +93,17 @@ def get_info(self, snap_name: str, *, arch: str = None) -> SnapInfo: params["architecture"] = arch logger.debug("Getting information for {}".format(snap_name)) url = "/v2/snaps/info/{}".format(snap_name) - resp = self.get(url, headers=headers, params=params) - if resp.status_code == 404: - raise errors.SnapNotFoundError(snap_name=snap_name, arch=arch) - resp.raise_for_status() + try: + response = self.get(url, headers=headers, params=params) + except craft_store.errors.StoreServerError as store_error: + if store_error.response.status_code == 404: + raise errors.SnapNotFoundError( + snap_name=snap_name, arch=arch + ) from store_error + raise - return SnapInfo(resp.json()) + return SnapInfo(response.json()) def get_assertion( self, assertion_type: str, snap_id: str @@ -111,7 +118,11 @@ def get_assertion( headers = self._get_default_headers(api="v1") logger.debug("Getting snap-declaration for {}".format(snap_id)) url = f"/api/v1/snaps/assertions/{assertion_type}/{constants.DEFAULT_SERIES}/{snap_id}" - response = self.get(url, headers=headers) - if response.status_code != 200: - raise errors.SnapNotFoundError(snap_id=snap_id) + try: + response = self.get(url, headers=headers) + except craft_store.errors.StoreServerError as store_error: + if store_error.response.status_code == 404: + raise errors.SnapNotFoundError(snap_id=snap_id) from store_error + raise + return response.json() diff --git a/snapcraft/storeapi/_status_tracker.py b/snapcraft_legacy/storeapi/_status_tracker.py similarity index 100% rename from snapcraft/storeapi/_status_tracker.py rename to snapcraft_legacy/storeapi/_status_tracker.py diff --git a/snapcraft/storeapi/_store_client.py b/snapcraft_legacy/storeapi/_store_client.py similarity index 75% rename from snapcraft/storeapi/_store_client.py rename to snapcraft_legacy/storeapi/_store_client.py index 59f8e0f380..fe365177a3 100644 --- a/snapcraft/storeapi/_store_client.py +++ b/snapcraft_legacy/storeapi/_store_client.py @@ -16,61 +16,80 @@ import logging import os +import platform from time import sleep -from typing import Any, Dict, Iterable, List, Optional, TextIO, Union +from typing import Any, Dict, List, Optional, Sequence, Union +import craft_store import requests -from snapcraft.internal.indicators import download_requests_stream - -from . import _upload, errors, http_clients, metrics +from snapcraft_legacy.internal.indicators import download_requests_stream +from . import agent, constants, errors, metrics from ._dashboard_api import DashboardAPI from ._snap_api import SnapAPI -from ._up_down_client import UpDownClient from .constants import DEFAULT_SERIES -from .v2 import channel_map, releases, validation_sets, whoami +from .v2 import releases, validation_sets, whoami logger = logging.getLogger(__name__) +def _get_hostname() -> str: + """Return the computer's network name or UNNKOWN if it cannot be determined.""" + hostname = platform.node() + if not hostname: + hostname = "UNKNOWN" + return hostname + + class StoreClient: """High-level client Snap resources.""" - @property - def use_candid(self) -> bool: - return isinstance(self.auth_client, http_clients.CandidClient) + def __init__(self, ephemeral=False) -> None: + user_agent = agent.get_user_agent() - def __init__(self, use_candid: bool = False) -> None: - super().__init__() + self._root_url = os.getenv("STORE_DASHBOARD_URL", constants.STORE_DASHBOARD_URL) + storage_base_url = os.getenv("STORE_UPLOAD_URL", constants.STORE_UPLOAD_URL) - self.client = http_clients.Client() + self.client = craft_store.HTTPClient(user_agent=user_agent) - candid_has_credentials = http_clients.CandidClient.has_credentials() - logger.debug( - f"Candid forced: {use_candid}. Candid credentials: {candid_has_credentials}." - ) - if use_candid or candid_has_credentials: - self.auth_client: http_clients.AuthClient = http_clients.CandidClient() + if self.use_candid() is True: + self.auth_client = craft_store.StoreClient( + application_name="snapcraft", + base_url=self._root_url, + storage_base_url=storage_base_url, + endpoints=craft_store.endpoints.SNAP_STORE, + user_agent=user_agent, + environment_auth=constants.ENVIRONMENT_STORE_CREDENTIALS, + ephemeral=ephemeral, + ) else: - self.auth_client = http_clients.UbuntuOneAuthClient() + self.auth_client = craft_store.UbuntuOneStoreClient( + application_name="snapcraft", + base_url=self._root_url, + storage_base_url=storage_base_url, + auth_url=os.getenv("UBUNTU_ONE_SSO_URL", constants.UBUNTU_ONE_SSO_URL), + endpoints=craft_store.endpoints.U1_SNAP_STORE, + user_agent=user_agent, + environment_auth=constants.ENVIRONMENT_STORE_CREDENTIALS, + ephemeral=ephemeral, + ) self.snap = SnapAPI(self.client) self.dashboard = DashboardAPI(self.auth_client) - self._updown = UpDownClient(self.client) + + @staticmethod + def use_candid() -> bool: + return os.getenv(constants.ENVIRONMENT_STORE_AUTH) == "candid" def login( self, *, - acls: Iterable[str] = None, - channels: Iterable[str] = None, - packages: Iterable[Dict[str, str]] = None, - expires: str = None, - config_fd: TextIO = None, + ttl: int, + acls: Optional[Sequence[str]] = None, + channels: Optional[Sequence[str]] = None, + packages: Optional[Sequence[str]] = None, **kwargs, - ) -> None: - if config_fd is not None: - return self.auth_client.login(config_fd=config_fd, **kwargs) - + ) -> str: if acls is None: acls = [ "package_access", @@ -82,16 +101,20 @@ def login( "package_update", ] - macaroon = self.dashboard.get_macaroon( - acls=acls, - packages=packages, + if channels is None: + channels = [] + + if packages is None: + packages = [] + + return self.auth_client.login( + permissions=acls, + description=f"snapcraft@{_get_hostname()}", + ttl=ttl, channels=channels, - expires=expires, + packages=[craft_store.endpoints.Package(p, "snap") for p in packages], + **kwargs, ) - self.auth_client.login(macaroon=macaroon, **kwargs) - - def export_login(self, *, config_fd: TextIO, encode=False) -> None: - self.auth_client.export_login(config_fd=config_fd, encode=encode) def logout(self): self.auth_client.logout() @@ -138,30 +161,6 @@ def upload_precheck(self, snap_name): def push_snap_build(self, snap_id, snap_build): return self.dashboard.push_snap_build(snap_id, snap_build) - def upload( - self, - snap_name, - snap_filename, - delta_format=None, - source_hash=None, - target_hash=None, - delta_hash=None, - built_at=None, - channels: Optional[List[str]] = None, - ): - updown_data = _upload.upload_files(snap_filename, self._updown) - - return self.dashboard.snap_upload_metadata( - snap_name, - updown_data, - delta_format=delta_format, - source_hash=source_hash, - target_hash=target_hash, - delta_hash=delta_hash, - built_at=built_at, - channels=channels, - ) - def release( self, snap_name, @@ -193,9 +192,6 @@ def get_snap_status(self, snap_name, arch=None): return response - def get_snap_channel_map(self, *, snap_name: str) -> channel_map.ChannelMap: - return self.dashboard.get_snap_channel_map(snap_name=snap_name) - def get_metrics( self, *, @@ -222,11 +218,9 @@ def get_validation_sets( ) -> validation_sets.ValidationSets: return self.dashboard.get_validation_sets(name=name, sequence=sequence) - def close_channels(self, snap_id, channel_names): - return self.dashboard.close_channels(snap_id, channel_names) - + @classmethod def download( - self, + cls, snap_name, *, risk: str, @@ -235,7 +229,7 @@ def download( arch: Optional[str] = None, except_hash: str = "", ): - snap_info = self.snap.get_info(snap_name) + snap_info = SnapAPI().get_info(snap_name) channel_mapping = snap_info.get_channel_mapping( risk=risk, track=track, arch=arch ) @@ -245,12 +239,13 @@ def download( try: channel_mapping.download.verify(download_path) except errors.StoreDownloadError: - self._download_snap(channel_mapping.download, download_path) + cls._download_snap(channel_mapping.download, download_path) channel_mapping.download.verify(download_path) return channel_mapping.download.sha3_384 - def _download_snap(self, download_details, download_path): + @classmethod + def _download_snap(cls, download_details, download_path): # we only resume when redirected to our CDN since we use internap's # special sauce. total_read = 0 @@ -271,7 +266,7 @@ def _download_snap(self, download_details, download_path): if resume_possible and os.path.exists(download_path): total_read = os.path.getsize(download_path) headers["Range"] = "bytes={}-".format(total_read) - request = self.client.request( + request = craft_store.HTTPClient(user_agent=agent.get_user_agent()).request( "GET", download_url, headers=headers, stream=True ) request.raise_for_status() diff --git a/snapcraft/storeapi/http_clients/agent.py b/snapcraft_legacy/storeapi/agent.py similarity index 84% rename from snapcraft/storeapi/http_clients/agent.py rename to snapcraft_legacy/storeapi/agent.py index e540f6ae5f..04d516e75b 100644 --- a/snapcraft/storeapi/http_clients/agent.py +++ b/snapcraft_legacy/storeapi/agent.py @@ -17,10 +17,10 @@ import os import sys -import snapcraft -from snapcraft import project -from snapcraft.internal import os_release -from snapcraft.internal.errors import OsReleaseNameError, OsReleaseVersionIdError +import snapcraft_legacy +from snapcraft_legacy import project +from snapcraft_legacy.internal import os_release +from snapcraft_legacy.internal.errors import OsReleaseNameError, OsReleaseVersionIdError def _is_ci_env(): @@ -55,4 +55,4 @@ def get_user_agent(platform: str = sys.platform) -> str: else: os_platform = platform.title() - return f"snapcraft/{snapcraft.__version__} {testing}{os_platform} ({arch})" + return f"snapcraft/{snapcraft_legacy.__version__} {testing}{os_platform} ({arch})" diff --git a/snapcraft/storeapi/channels.py b/snapcraft_legacy/storeapi/channels.py similarity index 100% rename from snapcraft/storeapi/channels.py rename to snapcraft_legacy/storeapi/channels.py diff --git a/snapcraft/storeapi/constants.py b/snapcraft_legacy/storeapi/constants.py similarity index 76% rename from snapcraft/storeapi/constants.py rename to snapcraft_legacy/storeapi/constants.py index b032fead12..b03d4fc794 100644 --- a/snapcraft/storeapi/constants.py +++ b/snapcraft_legacy/storeapi/constants.py @@ -21,9 +21,11 @@ SCAN_STATUS_POLL_DELAY = 5 SCAN_STATUS_POLL_RETRIES = 5 -STORE_DASHBOARD_URL = "https://dashboard.snapcraft.io/" -STORE_API_URL = "https://api.snapcraft.io/" -STORE_UPLOAD_URL = "https://upload.apps.ubuntu.com/" +STORE_DASHBOARD_URL = "https://dashboard.snapcraft.io" +STORE_API_URL = "https://api.snapcraft.io" +STORE_UPLOAD_URL = "storage.snapcraftcontent.com" + +UBUNTU_ONE_SSO_URL = "https://login.ubuntu.com" # Messages and warnings. MISSING_AGREEMENT = "Developer has not signed agreement." @@ -46,3 +48,13 @@ "We strongly recommend enabling multi-factor authentication: " "https://help.ubuntu.com/community/SSO/FAQs/2FA" ) + +ENVIRONMENT_STORE_CREDENTIALS = "SNAPCRAFT_STORE_CREDENTIALS" +"""Environment variable where credentials can be picked up from.""" + +ENVIRONMENT_STORE_AUTH = "SNAPCRAFT_STORE_AUTH" +"""Environment variable used to set an alterntive login method. + +The only setting that changes the behavior is `candid`, every +other value uses Ubuntu SSO. +""" diff --git a/snapcraft/storeapi/errors.py b/snapcraft_legacy/storeapi/errors.py similarity index 99% rename from snapcraft/storeapi/errors.py rename to snapcraft_legacy/storeapi/errors.py index 170a7ae0d0..ab0ba4ac44 100644 --- a/snapcraft/storeapi/errors.py +++ b/snapcraft_legacy/storeapi/errors.py @@ -20,8 +20,8 @@ from simplejson.scanner import JSONDecodeError -from snapcraft import formatting_utils -from snapcraft.internal.errors import ( +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal.errors import ( SnapcraftError, SnapcraftException, SnapcraftReportableException, diff --git a/snapcraft/storeapi/info.py b/snapcraft_legacy/storeapi/info.py similarity index 99% rename from snapcraft/storeapi/info.py rename to snapcraft_legacy/storeapi/info.py index dff4ff0705..0dbc427338 100644 --- a/snapcraft/storeapi/info.py +++ b/snapcraft_legacy/storeapi/info.py @@ -17,7 +17,7 @@ import os from typing import Any, Dict, List, Optional -from snapcraft.file_utils import calculate_hash +from snapcraft_legacy.file_utils import calculate_hash from . import errors diff --git a/snapcraft/storeapi/metrics.py b/snapcraft_legacy/storeapi/metrics.py similarity index 100% rename from snapcraft/storeapi/metrics.py rename to snapcraft_legacy/storeapi/metrics.py diff --git a/snapcraft/storeapi/status.py b/snapcraft_legacy/storeapi/status.py similarity index 100% rename from snapcraft/storeapi/status.py rename to snapcraft_legacy/storeapi/status.py diff --git a/snapcraft/storeapi/v2/__init__.py b/snapcraft_legacy/storeapi/v2/__init__.py similarity index 100% rename from snapcraft/storeapi/v2/__init__.py rename to snapcraft_legacy/storeapi/v2/__init__.py diff --git a/snapcraft/storeapi/v2/_api_schema.py b/snapcraft_legacy/storeapi/v2/_api_schema.py similarity index 59% rename from snapcraft/storeapi/v2/_api_schema.py rename to snapcraft_legacy/storeapi/v2/_api_schema.py index 8dc478ef6f..34bf16f492 100644 --- a/snapcraft/storeapi/v2/_api_schema.py +++ b/snapcraft_legacy/storeapi/v2/_api_schema.py @@ -144,203 +144,6 @@ "type": "object", } - -CHANNEL_MAP_JSONSCHEMA: Dict[str, Any] = { - "properties": { - "channel-map": { - "items": { - "properties": { - "architecture": {"type": "string"}, - "channel": { - "description": 'The channel name, including "latest/" for the latest track.', - "type": "string", - }, - "expiration-date": { - "description": "The date when this release expires, in RFC 3339 format. If null, the release does not expire.", - "format": "date-time", - "type": ["string", "null"], - }, - "progressive": { - "properties": { - "paused": {"type": ["boolean", "null"]}, - "percentage": {"type": ["number", "null"]}, - "current-percentage": {"type": ["number", "null"]}, - }, - "required": ["paused", "percentage", "current-percentage"], - "type": "object", - }, - "revision": {"type": "integer"}, - "when": { - "description": "The date when this release was made, in RFC 3339 format.", - "format": "date-time", - "type": "string", - }, - }, - "required": [ - "architecture", - "channel", - "expiration-date", - "progressive", - "revision", - # "when" - ], - "type": "object", - }, - "minItems": 0, - "type": "array", - }, - "revisions": { - "items": { - "properties": { - "architectures": { - "items": {"type": "string"}, - "minItems": 1, - "type": "array", - }, - "attributes": {"type": "object"}, - "base": {"type": ["string", "null"]}, - "build-url": {"type": ["string", "null"]}, - "confinement": { - "enum": ["strict", "classic", "devmode"], - "type": "string", - }, - "created-at": {"format": "date-time", "type": "string"}, - "epoch": { - "properties": { - "read": { - "items": {"type": "integer"}, - "minItems": 1, - "type": ["array", "null"], - }, - "write": { - "items": {"type": "integer"}, - "minItems": 1, - "type": ["array", "null"], - }, - }, - "required": ["read", "write"], - "type": "object", - }, - "grade": {"enum": ["stable", "devel"], "type": "string"}, - "revision": {"type": "integer"}, - "sha3-384": {"type": "string"}, - "size": {"type": "integer"}, - "version": {"type": "string"}, - }, - "required": [ - "architectures", - # "attributes", - # "base", - # "build-url", - # "confinement", - # "created-at", - # "epoch", - # "grade", - "revision", - # "sha3-384", - # "size", - # "status", - "version", - ], - "type": "object", - }, - "minItems": 0, - "type": "array", - }, - "snap": { - "description": "Metadata about the requested snap.", - "introduced_at": 6, - "properties": { - "channels": { - "description": "The list of most relevant channels for this snap. Branches are only included if there is a release for it.", - "introduced_at": 9, - "items": { - "description": "A list of channels and their metadata for the requested snap.", - "properties": { - "branch": { - "description": "The branch name for this channel, can be null.", - "type": ["string", "null"], - }, - "fallback": { - "description": "The name of the channel that this channel would fall back to if there were no releases in it. If null, this channel has no fallback channel.", - "type": ["string", "null"], - }, - "name": { - "description": 'The channel name, including "latest/" for the latest track.', - "type": "string", - }, - "risk": { - "description": "The risk name for this channel.", - "type": "string", - }, - "track": { - "description": "The track name for this channel.", - "type": "string", - }, - }, - "required": ["name", "track", "risk", "branch", "fallback"], - "type": "object", - }, - "minItems": 1, - "type": "array", - }, - "default-track": { - "description": "The default track name for this snap. If no default track is set, this value is null.", - "type": ["string", "null"], - }, - "id": { - "description": "The snap ID for this snap package.", - "type": "string", - }, - "name": {"description": "The snap package name.", "type": "string"}, - "private": { - "description": "Whether this snap is private or not.", - "type": "boolean", - }, - "tracks": { - "description": "An ordered list of most relevant tracks for this snap.", - "introduced_at": 9, - "items": { - "description": "An ordered list of tracks and their metadata for this snap.", - "properties": { - "creation-date": { - "description": "The track creation date, in ISO 8601 format.", - "format": "date-time", - "type": ["string", "null"], - }, - "name": { - "description": "The track name.", - "type": "string", - }, - "version-pattern": { - "description": "A Python regex to validate the versions being released to this track. If null, no validation is enforced.", - "type": ["string", "null"], - }, - }, - # pattern is documented as required but is not returned, - # version-pattern is returned instead. - "required": ["name", "creation-date", "version-pattern"], - "type": "object", - }, - "minItems": 1, - "type": "array", - }, - }, - "required": [ - # "id", - "channels", - # "default-track", - "name", - # "private", - # "tracks" - ], - "type": "object", - }, - }, - "required": ["channel-map", "revisions", "snap"], - "type": "object", -} - # Version 27, found at https://dashboard.snapcraft.io/docs/v2/en/tokens.html#api-tokens-whoami WHOAMI_JSONSCHEMA: Dict[str, Any] = { "properties": { diff --git a/snapcraft/storeapi/v2/releases.py b/snapcraft_legacy/storeapi/v2/releases.py similarity index 100% rename from snapcraft/storeapi/v2/releases.py rename to snapcraft_legacy/storeapi/v2/releases.py diff --git a/snapcraft/storeapi/v2/validation_sets.py b/snapcraft_legacy/storeapi/v2/validation_sets.py similarity index 100% rename from snapcraft/storeapi/v2/validation_sets.py rename to snapcraft_legacy/storeapi/v2/validation_sets.py diff --git a/snapcraft/storeapi/v2/whoami.py b/snapcraft_legacy/storeapi/v2/whoami.py similarity index 100% rename from snapcraft/storeapi/v2/whoami.py rename to snapcraft_legacy/storeapi/v2/whoami.py diff --git a/snapcraft/yaml_utils/__init__.py b/snapcraft_legacy/yaml_utils/__init__.py similarity index 91% rename from snapcraft/yaml_utils/__init__.py rename to snapcraft_legacy/yaml_utils/__init__.py index 3fff3e7f5b..42e49f4a35 100644 --- a/snapcraft/yaml_utils/__init__.py +++ b/snapcraft_legacy/yaml_utils/__init__.py @@ -17,11 +17,12 @@ import codecs import collections import logging +import os from typing import Any, Dict, Optional, TextIO, Union import yaml -from snapcraft.yaml_utils.errors import YamlValidationError +from snapcraft_legacy.yaml_utils.errors import YamlValidationError logger = logging.getLogger(__name__) @@ -29,9 +30,14 @@ # The C-based loaders/dumpers aren't available everywhere, but they're much faster. # Use them if possible. If not, we could fallback to the normal loader/dumper, but # they actually behave differently, so raise an error instead. - from yaml import CSafeDumper, CSafeLoader # type: ignore + from yaml import CSafeDumper as SafeDumper # type: ignore + from yaml import CSafeLoader as SafeLoader # type: ignore except ImportError: - raise RuntimeError("Snapcraft requires PyYAML to be built with libyaml bindings") + if not os.getenv("SNAPCRAFT_IGNORE_YAML_BINDINGS"): + raise RuntimeError( + "Snapcraft requires PyYAML to be built with libyaml bindings" + ) + from yaml import SafeDumper, SafeLoader def load_yaml_file(yaml_file_path: str) -> collections.OrderedDict: @@ -96,7 +102,7 @@ def dump( ) -class _SafeOrderedLoader(CSafeLoader): +class _SafeOrderedLoader(SafeLoader): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -105,7 +111,7 @@ def __init__(self, *args, **kwargs): ) -class _SafeOrderedDumper(CSafeDumper): +class _SafeOrderedDumper(SafeDumper): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.add_representer(str, _str_presenter) diff --git a/snapcraft/yaml_utils/errors.py b/snapcraft_legacy/yaml_utils/errors.py similarity index 98% rename from snapcraft/yaml_utils/errors.py rename to snapcraft_legacy/yaml_utils/errors.py index 4539d67188..c3adb14954 100644 --- a/snapcraft/yaml_utils/errors.py +++ b/snapcraft_legacy/yaml_utils/errors.py @@ -18,8 +18,8 @@ from collections import OrderedDict from typing import Dict, List -from snapcraft import formatting_utils -from snapcraft.internal.errors import SnapcraftError +from snapcraft_legacy import formatting_utils +from snapcraft_legacy.internal.errors import SnapcraftError _VALIDATION_ERROR_CAUSES = { "maxLength": "maximum length is {validator_value}", diff --git a/spread.yaml b/spread.yaml index 89ee1b01be..6b1280d407 100644 --- a/spread.yaml +++ b/spread.yaml @@ -38,6 +38,7 @@ backends: # -native is added for clarity and for ubuntu-20.04* to match. - ubuntu-18.04 - ubuntu-20.04 + - ubuntu-22.04 google: key: '$(HOST: echo "$SPREAD_GOOGLE_KEY")' location: snapd-spread/us-east1-b @@ -50,6 +51,9 @@ backends: workers: 6 image: ubuntu-2004-64 storage: 40G + - ubuntu-22.04-64: + workers: 6 + image: ubuntu-2204-64 multipass: type: adhoc @@ -97,6 +101,10 @@ backends: workers: 1 username: root password: ubuntu + - ubuntu-22.04-64: + workers: 1 + username: root + password: ubuntu autopkgtest: type: adhoc @@ -145,6 +153,22 @@ backends: - ubuntu-20.04-arm64: username: ubuntu password: ubuntu + # Jammy + - ubuntu-22.04-amd64: + username: ubuntu + password: ubuntu + - ubuntu-22.04-ppc64el: + username: ubuntu + password: ubuntu + - ubuntu-22.04-armhf: + username: ubuntu + password: ubuntu + - ubuntu-22.04-s390x: + username: ubuntu + password: ubuntu + - ubuntu-22.04-arm64: + username: ubuntu + password: ubuntu exclude: [snaps-cache/] @@ -183,16 +207,21 @@ prepare: | # Remove lxd and lxd-client deb packages as our implementation (pylxd) does not # nicely handle the snap and deb being installed at the same time. apt-get remove --purge --yes lxd lxd-client - # Install and setup the lxd snap - snap install lxd - # Add the ubuntu user to the lxd group. - adduser ubuntu lxd fi + # Install and setup the lxd snap + snap install lxd + # Add the ubuntu user to the lxd group. + adduser ubuntu lxd + lxd init --auto # Hold snap refreshes for 24h. snap set system refresh.hold="$(date --date=tomorrow +%Y-%m-%dT%H:%M:%S%:z)" - snap watch --last=auto-refresh? - snap watch --last=install? + if ! snap watch --last=auto-refresh?; then + journalctl -xe + fi + if ! snap watch --last=install?; then + journalctl -xe + fi if [ "$SPREAD_SYSTEM" = "ubuntu-18.04-64" ] || [ "$SPREAD_SYSTEM" = "ubuntu-20.04-64" ]; then /snap/bin/lxd waitready --timeout=30 @@ -219,10 +248,24 @@ prepare: | git commit -m "Testing Commit" popd + # TODO remove once core22 is stable + snap install core22 --edge + restore-each: | "$TOOLS_DIR"/restore.sh suites: + tests/spread/core22/: + summary: core22 specific tests + systems: + - ubuntu-22.04 + - ubuntu-22.04-64 + - ubuntu-22.04-amd64 + - ubuntu-22.04-arm64 + - ubuntu-22.04-armhf + - ubuntu-22.04-s390x + - ubuntu-22.04-ppc64el + # General, core suite tests/spread/general/: summary: tests of snapcraft core functionality @@ -383,6 +426,10 @@ suites: summary: tests of snapcraft's v2 plugins systems: - ubuntu-20.04* + tests/spread/plugins/craft-parts/: + summary: tests of snapcraft's craft-part's based plugins + systems: + - ubuntu-22.04* # Extensions tests tests/spread/extensions/: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..702897bb88 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,61 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +import keyring +import pytest +import xdg +from craft_store.auth import MemoryKeyring + + +@pytest.fixture(autouse=True) +def temp_xdg(tmpdir, mocker): + """Use a temporary locaction for XDG directories.""" + + mocker.patch( + "xdg.BaseDirectory.xdg_config_home", new=os.path.join(tmpdir, ".config") + ) + mocker.patch("xdg.BaseDirectory.xdg_data_home", new=os.path.join(tmpdir, ".local")) + mocker.patch("xdg.BaseDirectory.xdg_cache_home", new=os.path.join(tmpdir, ".cache")) + mocker.patch( + "xdg.BaseDirectory.xdg_config_dirs", new=[xdg.BaseDirectory.xdg_config_home] + ) + mocker.patch( + "xdg.BaseDirectory.xdg_data_dirs", new=[xdg.BaseDirectory.xdg_data_home] + ) + mocker.patch.dict(os.environ, {"XDG_CONFIG_HOME": os.path.join(tmpdir, ".config")}) + + +@pytest.fixture +def new_dir(tmp_path): + """Change to a new temporary directory.""" + + cwd = os.getcwd() + os.chdir(tmp_path) + + yield tmp_path + + os.chdir(cwd) + + +@pytest.fixture +def memory_keyring(): + """In memory keyring backend for testing.""" + current_keyring = keyring.get_keyring() + keyring.set_keyring(MemoryKeyring()) + yield + keyring.set_keyring(current_keyring) diff --git a/tests/unit/build_providers/lxd/__init__.py b/tests/legacy/__init__.py similarity index 100% rename from tests/unit/build_providers/lxd/__init__.py rename to tests/legacy/__init__.py diff --git a/tests/data/icon.png b/tests/legacy/data/icon.png similarity index 100% rename from tests/data/icon.png rename to tests/legacy/data/icon.png diff --git a/tests/data/invalid.snap b/tests/legacy/data/invalid.snap similarity index 100% rename from tests/data/invalid.snap rename to tests/legacy/data/invalid.snap diff --git a/tests/data/test-snap-with-icon-license-title.snap b/tests/legacy/data/test-snap-with-icon-license-title.snap similarity index 100% rename from tests/data/test-snap-with-icon-license-title.snap rename to tests/legacy/data/test-snap-with-icon-license-title.snap diff --git a/tests/data/test-snap-with-icon.snap b/tests/legacy/data/test-snap-with-icon.snap similarity index 100% rename from tests/data/test-snap-with-icon.snap rename to tests/legacy/data/test-snap-with-icon.snap diff --git a/tests/data/test-snap-with-started-at.snap b/tests/legacy/data/test-snap-with-started-at.snap similarity index 100% rename from tests/data/test-snap-with-started-at.snap rename to tests/legacy/data/test-snap-with-started-at.snap diff --git a/tests/data/test-snap.snap b/tests/legacy/data/test-snap.snap similarity index 100% rename from tests/data/test-snap.snap rename to tests/legacy/data/test-snap.snap diff --git a/tests/data/test.desktop b/tests/legacy/data/test.desktop similarity index 100% rename from tests/data/test.desktop rename to tests/legacy/data/test.desktop diff --git a/tests/fake_servers/__init__.py b/tests/legacy/fake_servers/__init__.py similarity index 99% rename from tests/fake_servers/__init__.py rename to tests/legacy/fake_servers/__init__.py index 1c5f9c75d9..30debdbc4b 100644 --- a/tests/fake_servers/__init__.py +++ b/tests/legacy/fake_servers/__init__.py @@ -26,7 +26,7 @@ # we do not want snapcraft imports for the integration tests try: - from snapcraft import yaml_utils + from snapcraft_legacy import yaml_utils except ImportError: import yaml as yaml_utils # type: ignore diff --git a/tests/fake_servers/api.py b/tests/legacy/fake_servers/api.py similarity index 99% rename from tests/fake_servers/api.py rename to tests/legacy/fake_servers/api.py index c7de4b3641..1b02d5b3d0 100644 --- a/tests/fake_servers/api.py +++ b/tests/legacy/fake_servers/api.py @@ -26,7 +26,7 @@ import pymacaroons from pyramid import response -from tests.fake_servers import base +from tests.legacy.fake_servers import base logger = logging.getLogger(__name__) @@ -941,7 +941,7 @@ def snap_binary_metadata(self, request): ) else: # POST/PUT - # snapcraft.storeapi._metadata._build_binary_request_data + # snapcraft_legacy.storeapi._metadata._build_binary_request_data if type(request.params["info"]) == bytes: info = json.loads(request.params["info"].decode()) else: diff --git a/tests/fake_servers/base.py b/tests/legacy/fake_servers/base.py similarity index 100% rename from tests/fake_servers/base.py rename to tests/legacy/fake_servers/base.py diff --git a/tests/fake_servers/search.py b/tests/legacy/fake_servers/search.py similarity index 96% rename from tests/fake_servers/search.py rename to tests/legacy/fake_servers/search.py index 813f8ec455..199010ce46 100644 --- a/tests/fake_servers/search.py +++ b/tests/legacy/fake_servers/search.py @@ -21,8 +21,8 @@ from pyramid import response -import tests -from tests.fake_servers import base +import tests.legacy +from tests.legacy.fake_servers import base logger = logging.getLogger(__name__) @@ -64,7 +64,7 @@ def info(self, request): def _get_info_payload(self, request): # core snap is used in integration tests with fake servers. snap = request.matchdict["snap"] - # tests/data/test-snap.snap + # tests/legacy/data/test-snap.snap test_sha3_384 = ( "8c0118831680a22090503ee5db98c88dd90ef551d80fc816" "dec968f60527216199dacc040cddfe5cec6870db836cb908" @@ -146,7 +146,7 @@ def download(self, request): # TODO create a test snap during the test instead of hardcoding it. # --elopio - 2016-05-01 snap_path = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap.snap" + os.path.dirname(tests.legacy.__file__), "data", "test-snap.snap" ) with open(snap_path, "rb") as snap_file: diff --git a/tests/fake_servers/snapd.py b/tests/legacy/fake_servers/snapd.py similarity index 99% rename from tests/fake_servers/snapd.py rename to tests/legacy/fake_servers/snapd.py index 6d60276558..bd0429ca05 100644 --- a/tests/fake_servers/snapd.py +++ b/tests/legacy/fake_servers/snapd.py @@ -17,7 +17,7 @@ from typing import Any, Dict, List # noqa from urllib import parse -from tests import fake_servers +from tests.legacy import fake_servers class FakeSnapdRequestHandler(fake_servers.BaseHTTPRequestHandler): diff --git a/tests/fake_servers/upload.py b/tests/legacy/fake_servers/upload.py similarity index 97% rename from tests/fake_servers/upload.py rename to tests/legacy/fake_servers/upload.py index 7229cd8b69..990620bb6b 100644 --- a/tests/fake_servers/upload.py +++ b/tests/legacy/fake_servers/upload.py @@ -20,7 +20,7 @@ from pyramid import response -from tests.fake_servers import base +from tests.legacy.fake_servers import base logger = logging.getLogger(__name__) diff --git a/tests/fixture_setup/__init__.py b/tests/legacy/fixture_setup/__init__.py similarity index 100% rename from tests/fixture_setup/__init__.py rename to tests/legacy/fixture_setup/__init__.py diff --git a/tests/fixture_setup/_fixtures.py b/tests/legacy/fixture_setup/_fixtures.py similarity index 97% rename from tests/fixture_setup/_fixtures.py rename to tests/legacy/fixture_setup/_fixtures.py index eb9067637a..50ede1576e 100644 --- a/tests/fixture_setup/_fixtures.py +++ b/tests/legacy/fixture_setup/_fixtures.py @@ -31,13 +31,13 @@ import fixtures import xdg -from tests import fake_servers -from tests.fake_servers import api, search, upload +from tests.legacy import fake_servers +from tests.legacy.fake_servers import api, search, upload from tests.subprocess_utils import call, call_with_output # we do not want snapcraft imports for the integration tests try: - from snapcraft import yaml_utils + from snapcraft_legacy import yaml_utils except ImportError: import yaml as yaml_utils # type: ignore @@ -283,7 +283,7 @@ def _start_fake_server(self): server_thread = threading.Thread(target=self.server.serve_forever) server_thread.start() self.addCleanup(self._stop_fake_server, server_thread) - self.url = "http://localhost:{}/".format(self.server.server_port) + self.url = "http://localhost:{}".format(self.server.server_port) def _stop_fake_server(self, thread): self.server.shutdown() @@ -339,23 +339,23 @@ def setUp(self): self.useFixture( fixtures.EnvironmentVariable( "STORE_DASHBOARD_URL", - "https://dashboard.staging.snapcraft.io/", + "https://dashboard.staging.snapcraft.io", ) ) self.useFixture( fixtures.EnvironmentVariable( "STORE_UPLOAD_URL", - "https://upload.apps.staging.ubuntu.com/", + "https://storage.staging.snapcraftcontent.com", ) ) self.useFixture( fixtures.EnvironmentVariable( - "UBUNTU_ONE_SSO_URL", "https://login.staging.ubuntu.com/" + "UBUNTU_ONE_SSO_URL", "https://login.staging.ubuntu.com" ) ) self.useFixture( fixtures.EnvironmentVariable( - "STORE_API_URL", "https://api.staging.snapcraft.io/" + "STORE_API_URL", "https://api.staging.snapcraft.io" ) ) @@ -711,14 +711,14 @@ def _setUp(self): self.addCleanup(patcher.stop) self.core_path = self.useFixture(fixtures.TempDir()).path - patcher = mock.patch("snapcraft.internal.common.get_installed_snap_path") + patcher = mock.patch("snapcraft_legacy.internal.common.get_installed_snap_path") mock_core_path = patcher.start() mock_core_path.return_value = self.core_path self.addCleanup(patcher.stop) self.content_dirs = set([]) mock_content_dirs = fixtures.MockPatch( - "snapcraft.project._project.Project._get_provider_content_dirs", + "snapcraft_legacy.project._project.Project._get_provider_content_dirs", return_value=self.content_dirs, ) self.useFixture(mock_content_dirs) diff --git a/tests/fixture_setup/_unittests.py b/tests/legacy/fixture_setup/_unittests.py similarity index 93% rename from tests/fixture_setup/_unittests.py rename to tests/legacy/fixture_setup/_unittests.py index 308ad7b63c..7209784c9c 100644 --- a/tests/fixture_setup/_unittests.py +++ b/tests/legacy/fixture_setup/_unittests.py @@ -27,9 +27,9 @@ import fixtures import jsonschema -import snapcraft -from snapcraft.internal import elf -from snapcraft.plugins._plugin_finder import get_plugin_for_base +import snapcraft_legacy +from snapcraft_legacy.internal import elf +from snapcraft_legacy.plugins._plugin_finder import get_plugin_for_base from tests.file_utils import get_snapcraft_path @@ -50,13 +50,13 @@ def __init__(self, **kwargs): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.project.Project") + patcher = mock.patch("snapcraft_legacy.project.Project") patcher.start() self.addCleanup(patcher.stop) # Special handling is required as ProjectOptions attributes are # handled with the @property decorator. - project_options_t = type(snapcraft.project.Project.return_value) + project_options_t = type(snapcraft_legacy.project.Project.return_value) for key in self._kwargs: setattr(project_options_t, key, self._kwargs[key]) @@ -67,13 +67,13 @@ class FakeMetadataExtractor(fixtures.Fixture): def __init__( self, extractor_name: str, - extractor: Callable[[str], snapcraft.extractors.ExtractedMetadata], + extractor: Callable[[str], snapcraft_legacy.extractors.ExtractedMetadata], exported_name="extract", ) -> None: super().__init__() self._extractor_name = extractor_name self._exported_name = exported_name - self._import_name = "snapcraft.extractors.{}".format(extractor_name) + self._import_name = "snapcraft_legacy.extractors.{}".format(extractor_name) self._extractor = extractor def _setUp(self) -> None: @@ -85,7 +85,7 @@ def _setUp(self) -> None: real_iter_modules = pkgutil.iter_modules def _fake_iter_modules(path): - if path == snapcraft.extractors.__path__: + if path == snapcraft_legacy.extractors.__path__: yield None, self._extractor_name, False else: yield real_iter_modules(path) @@ -109,7 +109,8 @@ def __init__(self, plugin_name, plugin_class): def _setUp(self): self.useFixture( fixtures.MockPatch( - "snapcraft.plugins.get_plugin_for_base", side_effect=self.get_plugin + "snapcraft_legacy.plugins.get_plugin_for_base", + side_effect=self.get_plugin, ) ) @@ -368,7 +369,7 @@ class FakeExtension(fixtures.Fixture): def __init__(self, extension_name, extension_class): super().__init__() - self._import_name = "snapcraft.internal.project_loader._extensions.{}".format( + self._import_name = "snapcraft_legacy.internal.project_loader._extensions.{}".format( extension_name ) self._extension_class = extension_class @@ -410,8 +411,8 @@ def __init__(self): self._email = "-" def _setUp(self): - original_check_call = snapcraft.internal.repo.snaps.check_call - original_check_output = snapcraft.internal.repo.snaps.check_output + original_check_call = snapcraft_legacy.internal.repo.snaps.check_call + original_check_output = snapcraft_legacy.internal.repo.snaps.check_output def side_effect_check_call(cmd, *args, **kwargs): return side_effect(original_check_call, cmd, *args, **kwargs) @@ -432,12 +433,14 @@ def side_effect(original, cmd, *args, **kwargs): self.useFixture( fixtures.MonkeyPatch( - "snapcraft.internal.repo.snaps.check_call", side_effect_check_call + "snapcraft_legacy.internal.repo.snaps.check_call", + side_effect_check_call, ) ) self.useFixture( fixtures.MonkeyPatch( - "snapcraft.internal.repo.snaps.check_output", side_effect_check_output + "snapcraft_legacy.internal.repo.snaps.check_output", + side_effect_check_output, ) ) @@ -498,10 +501,10 @@ def _setUp(self): import sys sys.path.append('{snapcraft_path!s}') - import snapcraft.cli.__main__ + import snapcraft_legacy.cli.__main__ if __name__ == '__main__': - snapcraft.cli.__main__.run_snapcraftctl( + snapcraft_legacy.cli.__main__.run_snapcraftctl( prog_name='snapcraftctl') """.format( snapcraft_path=snapcraft_path diff --git a/tests/fixture_setup/_unix.py b/tests/legacy/fixture_setup/_unix.py similarity index 95% rename from tests/fixture_setup/_unix.py rename to tests/legacy/fixture_setup/_unix.py index 39f53cc9b2..1584f116df 100644 --- a/tests/fixture_setup/_unix.py +++ b/tests/legacy/fixture_setup/_unix.py @@ -22,7 +22,7 @@ import fixtures -from tests.fake_servers import snapd +from tests.legacy.fake_servers import snapd class UnixHTTPServer(socketserver.UnixStreamServer): @@ -73,7 +73,7 @@ def setUp(self): os.unlink(snapd_fake_socket_path) socket_path_patcher = mock.patch( - "snapcraft.internal.repo.snaps.get_snapd_socket_path_template" + "snapcraft_legacy.internal.repo.snaps.get_snapd_socket_path_template" ) mock_socket_path = socket_path_patcher.start() mock_socket_path.return_value = "http+unix://{}/v2/{{}}".format( diff --git a/tests/fixture_setup/os_release.py b/tests/legacy/fixture_setup/os_release.py similarity index 94% rename from tests/fixture_setup/os_release.py rename to tests/legacy/fixture_setup/os_release.py index 723cfe3dd4..a0ed4cfd8b 100644 --- a/tests/fixture_setup/os_release.py +++ b/tests/legacy/fixture_setup/os_release.py @@ -20,7 +20,7 @@ import fixtures -from snapcraft.internal import os_release +from snapcraft_legacy.internal import os_release class FakeOsRelease(fixtures.Fixture): @@ -73,7 +73,7 @@ def _create_os_release(*args, **kwargs): return release patcher = mock.patch( - "snapcraft.internal.os_release.OsRelease", wraps=_create_os_release + "snapcraft_legacy.internal.os_release.OsRelease", wraps=_create_os_release ) patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/matchers.py b/tests/legacy/matchers.py similarity index 100% rename from tests/matchers.py rename to tests/legacy/matchers.py diff --git a/tests/legacy/unit/__init__.py b/tests/legacy/unit/__init__.py new file mode 100644 index 0000000000..96fac5e843 --- /dev/null +++ b/tests/legacy/unit/__init__.py @@ -0,0 +1,318 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import http.server +import logging +import os +import stat +import threading +from unittest import mock + +import apt +import fixtures +import progressbar +import testscenarios +import testtools + +from snapcraft_legacy.internal import common, steps +from tests.file_utils import get_snapcraft_path +from tests.legacy import fake_servers, fixture_setup +from tests.legacy.unit.part_loader import load_part + + +class ContainsList(list): + def __eq__(self, other): + return all([i[0] in i[1] for i in zip(self, other)]) + + +class MockOptions: + def __init__( + self, + source=None, + source_type=None, + source_branch=None, + source_tag=None, + source_subdir=None, + source_depth=None, + source_commit=None, + source_checksum=None, + disable_parallel=False, + ): + self.source = source + self.source_type = source_type + self.source_depth = source_depth + self.source_branch = source_branch + self.source_commit = source_commit + self.source_tag = source_tag + self.source_subdir = source_subdir + self.disable_parallel = disable_parallel + + +class IsExecutable: + """Match if a file path is executable.""" + + def __str__(self): + return "IsExecutable()" + + def match(self, file_path): + if not os.stat(file_path).st_mode & stat.S_IEXEC: + return testtools.matchers.Mismatch( + "Expected {!r} to be executable, but it was not".format(file_path) + ) + return None + + +class LinkExists: + """Match if a file path is a symlink.""" + + def __init__(self, expected_target=None): + self._expected_target = expected_target + + def __str__(self): + return "LinkExists()" + + def match(self, file_path): + if not os.path.exists(file_path): + return testtools.matchers.Mismatch( + "Expected {!r} to be a symlink, but it doesn't exist".format(file_path) + ) + + if not os.path.islink(file_path): + return testtools.matchers.Mismatch( + "Expected {!r} to be a symlink, but it was not".format(file_path) + ) + + target = os.readlink(file_path) + if target != self._expected_target: + return testtools.matchers.Mismatch( + "Expected {!r} to be a symlink pointing to {!r}, but it was " + "pointing to {!r}".format(file_path, self._expected_target, target) + ) + + return None + + +class TestCase(testscenarios.WithScenarios, testtools.TestCase): + @classmethod + def setUpClass(cls): + cls.fake_snapd = fixture_setup.FakeSnapd() + cls.fake_snapd.setUp() + + @classmethod + def tearDownClass(cls): + cls.fake_snapd.cleanUp() + + def setUp(self): + super().setUp() + temp_cwd_fixture = fixture_setup.TempCWD() + self.useFixture(temp_cwd_fixture) + self.path = temp_cwd_fixture.path + + # Use a separate path for XDG dirs, or changes there may be detected as + # source changes. + self.xdg_path = self.useFixture(fixtures.TempDir()).path + self.useFixture(fixture_setup.TempXDG(self.xdg_path)) + self.fake_terminal = fixture_setup.FakeTerminal() + self.useFixture(self.fake_terminal) + # Some tests will directly or indirectly change the plugindir, which + # is a module variable. Make sure that it is returned to the original + # value when a test ends. + self.addCleanup(common.set_plugindir, common.get_plugindir()) + self.addCleanup(common.set_schemadir, common.get_schemadir()) + self.addCleanup(common.set_extensionsdir, common.get_extensionsdir()) + self.addCleanup(common.set_keyringsdir, common.get_keyringsdir()) + self.addCleanup(common.reset_env) + common.set_schemadir(os.path.join(get_snapcraft_path(), "schema")) + self.fake_logger = fixtures.FakeLogger(level=logging.ERROR) + self.useFixture(self.fake_logger) + + # Some tests will change the apt Dir::Etc::Trusted and + # Dir::Etc::TrustedParts directories. Make sure they're properly reset. + self.addCleanup( + apt.apt_pkg.config.set, + "Dir::Etc::Trusted", + apt.apt_pkg.config.find_file("Dir::Etc::Trusted"), + ) + self.addCleanup( + apt.apt_pkg.config.set, + "Dir::Etc::TrustedParts", + apt.apt_pkg.config.find_file("Dir::Etc::TrustedParts"), + ) + + patcher = mock.patch("os.sched_getaffinity") + self.cpu_count = patcher.start() + self.cpu_count.return_value = {1, 2} + self.addCleanup(patcher.stop) + + # We do not want the paths to affect every test we have. + patcher = mock.patch( + "snapcraft_legacy.file_utils.get_snap_tool_path", side_effect=lambda x: x + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "snapcraft_legacy.internal.indicators.ProgressBar", new=SilentProgressBar + ) + patcher.start() + self.addCleanup(patcher.stop) + + # These are what we expect by default + self.snap_dir = os.path.join(os.getcwd(), "snap") + self.prime_dir = os.path.join(os.getcwd(), "prime") + self.stage_dir = os.path.join(os.getcwd(), "stage") + self.parts_dir = os.path.join(os.getcwd(), "parts") + self.local_plugins_dir = os.path.join(self.snap_dir, "plugins") + + # Use this host to run through the lifecycle tests + self.useFixture( + fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT", "host") + ) + + # Make sure snap installation does the right thing + self.fake_snapd.installed_snaps = [ + dict(name="core20", channel="stable", revision="10"), + dict(name="core18", channel="stable", revision="10"), + ] + self.fake_snapd.snaps_result = [ + dict(name="core20", channel="stable", revision="10"), + dict(name="core18", channel="stable", revision="10"), + ] + self.fake_snapd.find_result = [ + dict( + core20=dict( + channel="stable", + channels={"latest/stable": dict(confinement="strict")}, + ) + ), + dict( + core18=dict( + channel="stable", + channels={"latest/stable": dict(confinement="strict")}, + ) + ), + ] + self.fake_snapd.snap_details_func = None + + self.fake_snap_command = fixture_setup.FakeSnapCommand() + self.useFixture(self.fake_snap_command) + + # Avoid installing patchelf in the tests + self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_NO_PATCHELF", "1")) + + # Disable Sentry reporting for tests, otherwise they'll hang waiting + # for input + self.useFixture( + fixtures.EnvironmentVariable("SNAPCRAFT_ENABLE_ERROR_REPORTING", "false") + ) + + # Don't let the managed host variable leak into tests + self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_MANAGED_HOST")) + + machine = os.environ.get("SNAPCRAFT_TEST_MOCK_MACHINE", None) + self.base_environment = fixture_setup.FakeBaseEnvironment(machine=machine) + self.useFixture(self.base_environment) + + # Make sure "SNAPCRAFT_ENABLE_DEVELOPER_DEBUG" is reset between tests + self.useFixture( + fixtures.EnvironmentVariable("SNAPCRAFT_ENABLE_DEVELOPER_DEBUG") + ) + self.useFixture(fixture_setup.FakeSnapcraftctl()) + + # Don't let host SNAPCRAFT_BUILD_INFO variable leak into tests + self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_INFO")) + + def make_snapcraft_yaml(self, content, encoding="utf-8", location=""): + snap_dir = os.path.join(location, "snap") + os.makedirs(snap_dir, exist_ok=True) + snapcraft_yaml = os.path.join(snap_dir, "snapcraft.yaml") + with open(snapcraft_yaml, "w", encoding=encoding) as fp: + fp.write(content) + return snapcraft_yaml + + def verify_state(self, part_name, state_dir, expected_step_name): + self.assertTrue( + os.path.isdir(state_dir), + "Expected state directory for {}".format(part_name), + ) + + # Expect every step up to and including the specified one to be run + step = steps.get_step_by_name(expected_step_name) + for step in step.previous_steps() + [step]: + self.assertTrue( + os.path.exists(os.path.join(state_dir, step.name)), + "Expected {!r} to be run for {}".format(step.name, part_name), + ) + + def load_part( + self, + part_name, + plugin_name=None, + part_properties=None, + project=None, + stage_packages_repo=None, + snap_name="test-snap", + base="core18", + build_base=None, + confinement="strict", + snap_type="app", + ): + return load_part( + part_name=part_name, + plugin_name=plugin_name, + part_properties=part_properties, + project=project, + stage_packages_repo=stage_packages_repo, + snap_name=snap_name, + base=base, + build_base=build_base, + confinement=confinement, + snap_type=snap_type, + ) + + +class TestWithFakeRemoteParts(TestCase): + def setUp(self): + super().setUp() + self.useFixture(fixture_setup.FakeParts()) + + +class FakeFileHTTPServerBasedTestCase(TestCase): + def setUp(self): + super().setUp() + + self.useFixture(fixtures.EnvironmentVariable("no_proxy", "localhost,127.0.0.1")) + self.server = http.server.HTTPServer( + ("127.0.0.1", 0), fake_servers.FakeFileHTTPRequestHandler + ) + server_thread = threading.Thread(target=self.server.serve_forever) + self.addCleanup(server_thread.join) + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + server_thread.start() + + +class SilentProgressBar(progressbar.ProgressBar): + """A progress bar causing no spurious output during tests.""" + + def start(self): + pass + + def update(self, value=None): + pass + + def finish(self): + pass diff --git a/tests/unit/build_providers/__init__.py b/tests/legacy/unit/build_providers/__init__.py similarity index 91% rename from tests/unit/build_providers/__init__.py rename to tests/legacy/unit/build_providers/__init__.py index 092e836044..681fd541ec 100644 --- a/tests/unit/build_providers/__init__.py +++ b/tests/legacy/unit/build_providers/__init__.py @@ -18,10 +18,10 @@ from typing import Dict, Optional from unittest import mock -from snapcraft.internal.build_providers._base_provider import Provider -from snapcraft.internal.meta.snap import Snap -from snapcraft.project import Project -from tests import fixture_setup, unit +from snapcraft_legacy.internal.build_providers._base_provider import Provider +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit class ProviderImpl(Provider): @@ -146,7 +146,7 @@ def setUp(self): self.instance_name = "snapcraft-project-name" patcher = mock.patch( - "snapcraft.internal.build_providers._base_provider.SnapInjector" + "snapcraft_legacy.internal.build_providers._base_provider.SnapInjector" ) self.snap_injector_mock = patcher.start() self.addCleanup(patcher.stop) @@ -163,7 +163,7 @@ def setUp(self): self.instance_name = "snapcraft-project-name" patcher = mock.patch( - "snapcraft.internal.build_providers._base_provider.SnapInjector" + "snapcraft_legacy.internal.build_providers._base_provider.SnapInjector" ) self.snap_injector_mock = patcher.start() self.addCleanup(patcher.stop) @@ -171,7 +171,7 @@ def setUp(self): self.project = get_project() patcher = mock.patch( - "snapcraft.internal.build_providers._base_provider._get_platform", + "snapcraft_legacy.internal.build_providers._base_provider._get_platform", return_value="darwin", ) patcher.start() diff --git a/tests/unit/build_providers/conftest.py b/tests/legacy/unit/build_providers/conftest.py similarity index 91% rename from tests/unit/build_providers/conftest.py rename to tests/legacy/unit/build_providers/conftest.py index bc812338ee..f589bb2834 100644 --- a/tests/unit/build_providers/conftest.py +++ b/tests/legacy/unit/build_providers/conftest.py @@ -23,7 +23,7 @@ def snap_injector(): """Fake SnapManager""" patcher = mock.patch( - "snapcraft.internal.build_providers._base_provider.SnapInjector" + "snapcraft_legacy.internal.build_providers._base_provider.SnapInjector" ) snap_injector_mock = patcher.start() yield snap_injector_mock diff --git a/tests/unit/build_providers/multipass/__init__.py b/tests/legacy/unit/build_providers/lxd/__init__.py similarity index 100% rename from tests/unit/build_providers/multipass/__init__.py rename to tests/legacy/unit/build_providers/lxd/__init__.py diff --git a/tests/unit/build_providers/lxd/test_lxd.py b/tests/legacy/unit/build_providers/lxd/test_lxd.py similarity index 96% rename from tests/unit/build_providers/lxd/test_lxd.py rename to tests/legacy/unit/build_providers/lxd/test_lxd.py index 27fc4cf28b..3ef99089c0 100644 --- a/tests/unit/build_providers/lxd/test_lxd.py +++ b/tests/legacy/unit/build_providers/lxd/test_lxd.py @@ -22,11 +22,11 @@ from testtools.matchers import Equals, FileContains, FileExists -from snapcraft.internal.build_providers import _base_provider, errors -from snapcraft.internal.build_providers._lxd import LXD -from snapcraft.internal.errors import SnapcraftEnvironmentError -from snapcraft.internal.repo.errors import SnapdConnectionError -from tests.unit.build_providers import BaseProviderBaseTest +from snapcraft_legacy.internal.build_providers import _base_provider, errors +from snapcraft_legacy.internal.build_providers._lxd import LXD +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError +from snapcraft_legacy.internal.repo.errors import SnapdConnectionError +from tests.legacy.unit.build_providers import BaseProviderBaseTest if sys.platform == "linux": import pylxd @@ -173,7 +173,7 @@ def setUp(self): self.addCleanup(patcher.stop) patcher = mock.patch( - "snapcraft.internal.build_providers._base_provider.Provider.clean_project", + "snapcraft_legacy.internal.build_providers._base_provider.Provider.clean_project", return_value=True, ) @@ -485,7 +485,8 @@ def test_linux(self): # Thou shall not fail with mock.patch( - "snapcraft.internal.repo.Repo.is_package_installed", return_value=False + "snapcraft_legacy.internal.repo.Repo.is_package_installed", + return_value=False, ): LXD.ensure_provider() @@ -496,7 +497,8 @@ def test_linux_with_snap_and_deb_installed(self): # Thou shall not fail with mock.patch( - "snapcraft.internal.repo.Repo.is_package_installed", return_value=True + "snapcraft_legacy.internal.repo.Repo.is_package_installed", + return_value=True, ): raised = self.assertRaises(SnapcraftEnvironmentError, LXD.ensure_provider) @@ -521,7 +523,7 @@ def test_lxd_snap_not_installed(self): def test_snap_support_missing(self): with mock.patch( - "snapcraft.internal.repo.snaps.SnapPackage.is_snap_installed", + "snapcraft_legacy.internal.repo.snaps.SnapPackage.is_snap_installed", side_effect=SnapdConnectionError(snap_name="lxd", url="fake"), ): raised = self.assertRaises(errors.ProviderNotFound, LXD.ensure_provider) diff --git a/tests/unit/cache/__init__.py b/tests/legacy/unit/build_providers/multipass/__init__.py similarity index 100% rename from tests/unit/cache/__init__.py rename to tests/legacy/unit/build_providers/multipass/__init__.py diff --git a/tests/unit/build_providers/multipass/test_instance_info.py b/tests/legacy/unit/build_providers/multipass/test_instance_info.py similarity index 96% rename from tests/unit/build_providers/multipass/test_instance_info.py rename to tests/legacy/unit/build_providers/multipass/test_instance_info.py index f532d71072..fd6df8d098 100644 --- a/tests/unit/build_providers/multipass/test_instance_info.py +++ b/tests/legacy/unit/build_providers/multipass/test_instance_info.py @@ -18,11 +18,11 @@ from testtools.matchers import Equals -from snapcraft.internal.build_providers import errors -from snapcraft.internal.build_providers._multipass._instance_info import ( # noqa: E501 +from snapcraft_legacy.internal.build_providers import errors +from snapcraft_legacy.internal.build_providers._multipass._instance_info import ( # noqa: E501 InstanceInfo, ) -from tests import unit +from tests.legacy import unit class InstanceInfoGeneralTest(unit.TestCase): diff --git a/tests/unit/build_providers/multipass/test_multipass.py b/tests/legacy/unit/build_providers/multipass/test_multipass.py similarity index 96% rename from tests/unit/build_providers/multipass/test_multipass.py rename to tests/legacy/unit/build_providers/multipass/test_multipass.py index f8a22b3e81..ce45206617 100644 --- a/tests/unit/build_providers/multipass/test_multipass.py +++ b/tests/legacy/unit/build_providers/multipass/test_multipass.py @@ -23,11 +23,14 @@ import pytest from testtools.matchers import Equals -from snapcraft.internal import steps -from snapcraft.internal.build_providers import _base_provider, errors -from snapcraft.internal.build_providers._multipass import Multipass, MultipassCommand -from snapcraft.internal.errors import SnapcraftEnvironmentError -from tests.unit.build_providers import BaseProviderBaseTest, get_project +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.build_providers import _base_provider, errors +from snapcraft_legacy.internal.build_providers._multipass import ( + Multipass, + MultipassCommand, +) +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError +from tests.legacy.unit.build_providers import BaseProviderBaseTest, get_project _DEFAULT_INSTANCE_INFO = dedent( """\ @@ -77,7 +80,8 @@ def execute_effect(*, command, instance_name, hide_output): return b"" patcher = mock.patch( - "snapcraft.internal.build_providers._multipass." "_multipass.MultipassCommand", + "snapcraft_legacy.internal.build_providers._multipass." + "_multipass.MultipassCommand", spec=MultipassCommand, ) multipass_cmd_mock = patcher.start() @@ -113,7 +117,7 @@ def setUp(self): super().setUp() patcher = mock.patch( - "snapcraft.internal.build_providers._multipass." + "snapcraft_legacy.internal.build_providers._multipass." "_multipass.MultipassCommand", spec=MultipassCommand, ) @@ -121,7 +125,7 @@ def setUp(self): self.addCleanup(patcher.stop) patcher = mock.patch( - "snapcraft.internal.build_providers._base_provider.Provider.clean_project", + "snapcraft_legacy.internal.build_providers._base_provider.Provider.clean_project", return_value=True, ) patcher.start() diff --git a/tests/unit/build_providers/multipass/test_multipass_command.py b/tests/legacy/unit/build_providers/multipass/test_multipass_command.py similarity index 99% rename from tests/unit/build_providers/multipass/test_multipass_command.py rename to tests/legacy/unit/build_providers/multipass/test_multipass_command.py index 6b725d1445..e18faffd5f 100644 --- a/tests/unit/build_providers/multipass/test_multipass_command.py +++ b/tests/legacy/unit/build_providers/multipass/test_multipass_command.py @@ -23,9 +23,9 @@ import pytest from testtools.matchers import Equals -from snapcraft.internal.build_providers import errors -from snapcraft.internal.build_providers._multipass import MultipassCommand -from tests import unit +from snapcraft_legacy.internal.build_providers import errors +from snapcraft_legacy.internal.build_providers._multipass import MultipassCommand +from tests.legacy import unit class MultipassCommandBaseTest(unit.TestCase): diff --git a/tests/unit/build_providers/test_base_provider.py b/tests/legacy/unit/build_providers/test_base_provider.py similarity index 99% rename from tests/unit/build_providers/test_base_provider.py rename to tests/legacy/unit/build_providers/test_base_provider.py index a73b9e4b8c..e30236269c 100644 --- a/tests/unit/build_providers/test_base_provider.py +++ b/tests/legacy/unit/build_providers/test_base_provider.py @@ -26,10 +26,10 @@ import pytest from testtools.matchers import DirExists, EndsWith, Equals, Not -from snapcraft.internal import steps -from snapcraft.internal.build_providers import errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.project import Project +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.build_providers import errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.project import Project from . import ( BaseProviderBaseTest, diff --git a/tests/unit/build_providers/test_errors.py b/tests/legacy/unit/build_providers/test_errors.py similarity index 99% rename from tests/unit/build_providers/test_errors.py rename to tests/legacy/unit/build_providers/test_errors.py index 6a0b2a0fb4..28bf314394 100644 --- a/tests/unit/build_providers/test_errors.py +++ b/tests/legacy/unit/build_providers/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.build_providers import errors +from snapcraft_legacy.internal.build_providers import errors class TestErrorFormatting: diff --git a/tests/unit/build_providers/test_snap.py b/tests/legacy/unit/build_providers/test_snap.py similarity index 98% rename from tests/unit/build_providers/test_snap.py rename to tests/legacy/unit/build_providers/test_snap.py index f85244c629..bd9a20e415 100644 --- a/tests/unit/build_providers/test_snap.py +++ b/tests/legacy/unit/build_providers/test_snap.py @@ -22,12 +22,12 @@ import fixtures from testtools.matchers import Contains, Equals, FileContains, Not -from snapcraft.internal.build_providers._snap import ( +from snapcraft_legacy.internal.build_providers._snap import ( SnapInjector, _get_snap_channel, repo, ) -from tests import fixture_setup, unit +from tests.legacy import fixture_setup, unit from . import ProviderImpl, get_project @@ -36,7 +36,7 @@ class SnapInjectionTest(unit.TestCase): def setUp(self): super().setUp() - patcher = patch("snapcraft.internal.repo.snaps.get_assertion") + patcher = patch("snapcraft_legacy.internal.repo.snaps.get_assertion") self.get_assertion_mock = patcher.start() self.addCleanup(patcher.stop) @@ -502,7 +502,7 @@ def test_snapd_not_on_host_installs_from_store(self): snap_injector.add("snapcraft") with patch( - "snapcraft.internal.repo.snaps.SnapPackage.get_local_snap_info", + "snapcraft_legacy.internal.repo.snaps.SnapPackage.get_local_snap_info", side_effect=repo.errors.SnapdConnectionError("core", "url"), ): snap_injector.apply() diff --git a/tests/unit/cli/__init__.py b/tests/legacy/unit/cache/__init__.py similarity index 100% rename from tests/unit/cli/__init__.py rename to tests/legacy/unit/cache/__init__.py diff --git a/tests/unit/cache/conftest.py b/tests/legacy/unit/cache/conftest.py similarity index 96% rename from tests/unit/cache/conftest.py rename to tests/legacy/unit/cache/conftest.py index 6b730c7a88..88b40d0eae 100644 --- a/tests/unit/cache/conftest.py +++ b/tests/legacy/unit/cache/conftest.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.internal import cache +from snapcraft_legacy.internal import cache @pytest.fixture() diff --git a/tests/unit/cache/test_file.py b/tests/legacy/unit/cache/test_file.py similarity index 97% rename from tests/unit/cache/test_file.py rename to tests/legacy/unit/cache/test_file.py index e3aa424d29..75df63d771 100644 --- a/tests/unit/cache/test_file.py +++ b/tests/legacy/unit/cache/test_file.py @@ -17,7 +17,7 @@ import os import shutil -from snapcraft.file_utils import calculate_hash +from snapcraft_legacy.file_utils import calculate_hash class TestFileCache: diff --git a/tests/unit/cache/test_snap.py b/tests/legacy/unit/cache/test_snap.py similarity index 94% rename from tests/unit/cache/test_snap.py rename to tests/legacy/unit/cache/test_snap.py index cad7dcb26c..1c12df1e07 100644 --- a/tests/unit/cache/test_snap.py +++ b/tests/legacy/unit/cache/test_snap.py @@ -22,20 +22,20 @@ import fixtures from testtools.matchers import Contains, Equals, Not -import snapcraft -import tests -from snapcraft import file_utils -from snapcraft.internal import cache -from tests.unit.commands import CommandBaseTestCase +import snapcraft_legacy +import tests.legacy +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import cache +from tests.legacy.unit.commands import CommandBaseTestCase class SnapCacheBaseTestCase(CommandBaseTestCase): def setUp(self): super().setUp() - self.deb_arch = snapcraft.ProjectOptions().deb_arch + self.deb_arch = snapcraft_legacy.ProjectOptions().deb_arch self.snap_path = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap.snap" + os.path.dirname(tests.legacy.__file__), "data", "test-snap.snap" ) diff --git a/tests/unit/deltas/__init__.py b/tests/legacy/unit/cli/__init__.py similarity index 100% rename from tests/unit/deltas/__init__.py rename to tests/legacy/unit/cli/__init__.py diff --git a/tests/unit/cli/conftest.py b/tests/legacy/unit/cli/conftest.py similarity index 88% rename from tests/unit/cli/conftest.py rename to tests/legacy/unit/cli/conftest.py index 5ad8db1841..a183d9f5b6 100644 --- a/tests/unit/cli/conftest.py +++ b/tests/legacy/unit/cli/conftest.py @@ -21,8 +21,8 @@ @pytest.fixture def mock_echo_error(): - """Return a mock for snapcraft.cli.echo.error.""" - patcher = mock.patch("snapcraft.cli.echo.error") + """Return a mock for snapcraft_legacy.cli.echo.error.""" + patcher = mock.patch("snapcraft_legacy.cli.echo.error") yield patcher.start() patcher.stop() diff --git a/tests/unit/cli/test_echo.py b/tests/legacy/unit/cli/test_echo.py similarity index 89% rename from tests/unit/cli/test_echo.py rename to tests/legacy/unit/cli/test_echo.py index 721daed5b2..157ae3d6ba 100644 --- a/tests/unit/cli/test_echo.py +++ b/tests/legacy/unit/cli/test_echo.py @@ -21,13 +21,13 @@ import fixtures import pytest -from snapcraft.cli import echo -from tests import unit +from snapcraft_legacy.cli import echo +from tests.legacy import unit @pytest.fixture() def mock_click(): - with mock.patch("snapcraft.cli.echo.click", autospec=True) as mock_click: + with mock.patch("snapcraft_legacy.cli.echo.click", autospec=True) as mock_click: yield mock_click @@ -35,7 +35,7 @@ def mock_click(): def mock_shutil_get_terminal_size(): fake_terminal = os.terminal_size([80, 24]) with mock.patch( - "snapcraft.cli.echo.shutil.get_terminal_size", return_value=fake_terminal + "snapcraft_legacy.cli.echo.shutil.get_terminal_size", return_value=fake_terminal ) as mock_terminal_size: yield mock_terminal_size @@ -63,13 +63,13 @@ def test_is_tty_connected(self, tty_mock): self.assertEqual(result, True) - @mock.patch("snapcraft.cli.echo.is_tty_connected", return_value=False) + @mock.patch("snapcraft_legacy.cli.echo.is_tty_connected", return_value=False) def test_echo_confirm_is_not_tty(self, tty_mock): echo.confirm("message") self.click_confirm.mock.assert_not_called() - @mock.patch("snapcraft.cli.echo.is_tty_connected", return_value=True) + @mock.patch("snapcraft_legacy.cli.echo.is_tty_connected", return_value=True) def test_echo_confirm_is_tty(self, tty_mock): echo.confirm("message") @@ -82,7 +82,7 @@ def test_echo_confirm_is_tty(self, tty_mock): err=False, ) - @mock.patch("snapcraft.cli.echo.is_tty_connected", return_value=True) + @mock.patch("snapcraft_legacy.cli.echo.is_tty_connected", return_value=True) def test_echo_confirm_default(self, tty_mock): echo.confirm("message", default="the new default") @@ -95,13 +95,13 @@ def test_echo_confirm_default(self, tty_mock): err=False, ) - @mock.patch("snapcraft.cli.echo.is_tty_connected", return_value=False) + @mock.patch("snapcraft_legacy.cli.echo.is_tty_connected", return_value=False) def test_echo_prompt_is_not_tty(self, tty_mock): echo.prompt("message") self.click_prompt.mock.assert_not_called() - @mock.patch("snapcraft.cli.echo.is_tty_connected", return_value=True) + @mock.patch("snapcraft_legacy.cli.echo.is_tty_connected", return_value=True) def test_echo_prompt_is_tty(self, tty_mock): echo.prompt("message") @@ -117,7 +117,7 @@ def test_echo_prompt_is_tty(self, tty_mock): err=False, ) - @mock.patch("snapcraft.cli.echo.is_tty_connected", return_value=True) + @mock.patch("snapcraft_legacy.cli.echo.is_tty_connected", return_value=True) def test_echo_prompt_default(self, tty_mock): echo.prompt("message", default="the new default") diff --git a/tests/unit/cli/test_errors.py b/tests/legacy/unit/cli/test_errors.py similarity index 93% rename from tests/unit/cli/test_errors.py rename to tests/legacy/unit/cli/test_errors.py index bd2830d2ad..782715789d 100644 --- a/tests/unit/cli/test_errors.py +++ b/tests/legacy/unit/cli/test_errors.py @@ -25,19 +25,19 @@ import xdg from testtools.matchers import Equals, FileContains -import snapcraft.cli.echo -import snapcraft.internal.errors -from snapcraft.cli._errors import ( +import snapcraft_legacy.cli.echo +import snapcraft_legacy.internal.errors +from snapcraft_legacy.cli._errors import ( _get_exception_exit_code, _is_reportable_error, _print_exception_message, exception_handler, ) -from snapcraft.internal.build_providers.errors import ProviderExecError -from tests import fixture_setup, unit +from snapcraft_legacy.internal.build_providers.errors import ProviderExecError +from tests.legacy import fixture_setup, unit -class SnapcraftTError(snapcraft.internal.errors.SnapcraftError): +class SnapcraftTError(snapcraft_legacy.internal.errors.SnapcraftError): fmt = "{message}" @@ -48,7 +48,7 @@ def get_exit_code(self): return 123 -class SnapcraftTException(snapcraft.internal.errors.SnapcraftException): +class SnapcraftTException(snapcraft_legacy.internal.errors.SnapcraftException): def __init__(self): self._brief = "" self._resolution = "" @@ -80,7 +80,7 @@ class TestSnapcraftExceptionHandling(unit.TestCase): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.cli._errors.echo.error") + patcher = mock.patch("snapcraft_legacy.cli._errors.echo.error") self.error_mock = patcher.start() self.addCleanup(patcher.stop) @@ -146,7 +146,11 @@ def test_snapcraft_exception_minimal_with_resolution_and_url(self): def test_snapcraft_exception_reportable(self): exception = SnapcraftTException() exception._brief = "something's strange, in the neighborhood" - exc_info = (snapcraft.internal.errors.SnapcraftException, exception, None) + exc_info = ( + snapcraft_legacy.internal.errors.SnapcraftException, + exception, + None, + ) # Test default (is false). self.assertFalse(_is_reportable_error(exc_info)) @@ -192,7 +196,7 @@ def setUp(self): self.print_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.cli._errors.echo.error") + patcher = mock.patch("snapcraft_legacy.cli._errors.echo.error") self.error_mock = patcher.start() self.addCleanup(patcher.stop) @@ -241,12 +245,12 @@ class ErrorsTestCase(ErrorsBaseTestCase): def setUp(self): super().setUp() - @mock.patch.object(snapcraft.cli._errors, "RavenClient") - @mock.patch("snapcraft.internal.common.is_snap", return_value=False) + @mock.patch.object(snapcraft_legacy.cli._errors, "RavenClient") + @mock.patch("snapcraft_legacy.internal.common.is_snap", return_value=False) def test_handler_no_raven_traceback_non_snapcraft_exceptions_debug( self, is_snap_mock, raven_client_mock ): - snapcraft.cli._errors.RavenClient = None + snapcraft_legacy.cli._errors.RavenClient = None try: self.call_handler(RuntimeError("not a SnapcraftError"), True) except Exception: @@ -318,13 +322,13 @@ def test_provider_error_host(self, isfile_function): self.assertThat(self.print_exception_mock.call_count, Equals(1)) @mock.patch("os.path.isfile", return_value=False) - @mock.patch.object(snapcraft.cli._errors, "RavenClient") + @mock.patch.object(snapcraft_legacy.cli._errors, "RavenClient") def test_provider_error_inner(self, isfile_function, raven_client_mock): # Error raised inside the build provider self.useFixture( fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT", "managed-host") ) - snapcraft.cli._errors.RavenClient = "something" + snapcraft_legacy.cli._errors.RavenClient = "something" self._raise_other_error() self.move_mock.assert_not_called() self.assertThat(self.print_exception_mock.call_count, Equals(2)) @@ -347,15 +351,15 @@ def setUp(self): except ImportError: self.skipTest("raven needs to be installed for this test.") - patcher = mock.patch("snapcraft.cli.echo.prompt") + patcher = mock.patch("snapcraft_legacy.cli.echo.prompt") self.prompt_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.cli._errors.RequestsHTTPTransport") + patcher = mock.patch("snapcraft_legacy.cli._errors.RequestsHTTPTransport") self.raven_request_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.cli._errors.RavenClient") + patcher = mock.patch("snapcraft_legacy.cli._errors.RavenClient") self.raven_client_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/legacy/unit/cli/test_lifecycle.py b/tests/legacy/unit/cli/test_lifecycle.py new file mode 100644 index 0000000000..cfc703f136 --- /dev/null +++ b/tests/legacy/unit/cli/test_lifecycle.py @@ -0,0 +1,57 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from unittest import mock + +import pytest + +from snapcraft_legacy.cli import lifecycle + + +@pytest.mark.parametrize( + "output,pack_name,pack_dir", + [ + ("/tmp/output.snap", "output.snap", "/tmp"), + ("/tmp", None, "/tmp"), + ("output.snap", "output.snap", None), + ], +) +@pytest.mark.parametrize( + "compression", ["xz", "lzo", None], +) +@mock.patch("snapcraft_legacy.file_utils.get_host_tool_path", return_value="/bin/snap") +@mock.patch("snapcraft_legacy.cli.lifecycle._run_pack", return_value="ignore.snap") +def test_pack(mock_run_pack, mock_host_tool, compression, output, pack_name, pack_dir): + lifecycle._pack(directory="/my/snap", compression=compression, output=output) + + assert mock_host_tool.mock_calls == [ + mock.call(command_name="snap", package_name="snapd") + ] + + pack_command = ["/bin/snap", "pack"] + + if compression: + pack_command.extend(["--compression", compression]) + + if pack_name: + pack_command.extend(["--filename", pack_name]) + + pack_command.append("/my/snap") + + if pack_dir: + pack_command.append(pack_dir) + + assert mock_run_pack.mock_calls == [mock.call(pack_command)] diff --git a/tests/unit/cli/test_metrics.py b/tests/legacy/unit/cli/test_metrics.py similarity index 97% rename from tests/unit/cli/test_metrics.py rename to tests/legacy/unit/cli/test_metrics.py index 1154ccce78..6378729fa8 100644 --- a/tests/unit/cli/test_metrics.py +++ b/tests/legacy/unit/cli/test_metrics.py @@ -16,11 +16,11 @@ import pytest -from snapcraft.cli._metrics import ( +from snapcraft_legacy.cli._metrics import ( convert_metrics_to_table, get_series_label_from_metric_name, ) -from snapcraft.storeapi import metrics +from snapcraft_legacy.storeapi import metrics def test_get_series_label_from_metric_name(): diff --git a/tests/unit/cli/test_options.py b/tests/legacy/unit/cli/test_options.py similarity index 98% rename from tests/unit/cli/test_options.py rename to tests/legacy/unit/cli/test_options.py index aeca9bf1e0..ceda82c854 100644 --- a/tests/unit/cli/test_options.py +++ b/tests/legacy/unit/cli/test_options.py @@ -20,8 +20,8 @@ import fixtures from testtools.matchers import Equals -import snapcraft.cli._options as options -from tests import unit +import snapcraft_legacy.cli._options as options +from tests.legacy import unit class TestProviderOptions: @@ -324,7 +324,7 @@ def setUp(self): fixtures.MockPatch("os.geteuid", return_value=0) ).mock - @mock.patch("snapcraft.cli._options.warning") + @mock.patch("snapcraft_legacy.cli._options.warning") def test_click_warn_sudo(self, warning_mock): options._sanity_check_build_provider_flags("host") warning_mock.assert_called_once_with( diff --git a/tests/legacy/unit/commands/__init__.py b/tests/legacy/unit/commands/__init__.py new file mode 100644 index 0000000000..2eaa69ec9c --- /dev/null +++ b/tests/legacy/unit/commands/__init__.py @@ -0,0 +1,379 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2021 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import subprocess +from pathlib import PosixPath +from textwrap import dedent +from unittest import mock + +import craft_store +import fixtures +import pytest +import requests +from click.testing import CliRunner + +from snapcraft_legacy import storeapi +from snapcraft_legacy.cli._runner import run +from snapcraft_legacy.storeapi import metrics +from snapcraft_legacy.storeapi.v2.releases import Releases +from tests.legacy import fixture_setup, unit + +_sample_keys = [ + { + "name": "default", + "sha3-384": "vdEeQvRxmZ26npJCFaGnl-VfGz0lU2jZZkWp_s7E-RxVCNtH2_mtjcxq2NkDKkIp", + }, + { + "name": "another", + "sha3-384": "JsfToV5hO2eN9l89pYYCKXUioTERrZIIHUgQQd47jW8YNNBskupiIjWYd3KXLY_D", + }, +] + + +def get_sample_key(name): + for key in _sample_keys: + if key["name"] == name: + return key + raise KeyError(name) + + +original_check_output = subprocess.check_output + + +def mock_check_output(command, *args, **kwargs): + if isinstance(command[0], PosixPath): + command[0] = str(command[0]) + if ( + command[0].endswith("unsquashfs") + or command[0].endswith("xdelta3") + or command[0].endswith("file") + ): + return original_check_output(command, *args, **kwargs) + elif command[0].endswith("snap") and command[1:] == ["keys", "--json"]: + return json.dumps(_sample_keys) + elif command[0].endswith("snap") and command[1] == "export-key": + if not command[2].startswith("--account="): + raise AssertionError("Unhandled command: {}".format(command)) + account_id = command[2][len("--account=") :] + name = command[3] + # This isn't a full account-key-request assertion, but it's enough + # for testing. + return dedent( + """\ + type: account-key-request + account-id: {account_id} + name: {name} + public-key-sha3-384: {sha3_384} + """ + ).format( + account_id=account_id, name=name, sha3_384=get_sample_key(name)["sha3-384"] + ) + elif command[0].endswith("snap") and command[1:] == [ + "create-key", + "new-key", + ]: + pass + elif command[0].endswith("snap") and command[1] == "sign-build": + return b"Mocked assertion" + else: + raise AssertionError("Unhandled command: {}".format(command)) + + +@pytest.mark.usefixtures("memory_keyring") +class CommandBaseTestCase(unit.TestCase): + def setUp(self): + super().setUp() + self.runner = CliRunner() + + def run_command(self, args, **kwargs): + # For click testing, runner will overwrite the descriptors for stdio - + # ensure TTY always appears connected. + self.useFixture( + fixtures.MockPatch( + "snapcraft_legacy.cli.echo.is_tty_connected", return_value=True + ) + ) + + with mock.patch("sys.argv", args): + return self.runner.invoke(run, args, catch_exceptions=False, **kwargs) + + +class LifecycleCommandsBaseTestCase(CommandBaseTestCase): + def setUp(self): + super().setUp() + + self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT")) + + self.fake_lifecycle_clean = fixtures.MockPatch( + "snapcraft_legacy.internal.lifecycle.clean" + ) + self.useFixture(self.fake_lifecycle_clean) + + self.fake_lifecycle_execute = fixtures.MockPatch( + "snapcraft_legacy.internal.lifecycle.execute" + ) + self.useFixture(self.fake_lifecycle_execute) + + self.fake_pack = fixtures.MockPatch("snapcraft_legacy.cli.lifecycle._pack") + self.useFixture(self.fake_pack) + + self.snapcraft_yaml = fixture_setup.SnapcraftYaml( + self.path, + parts={ + "part0": {"plugin": "nil"}, + "part1": {"plugin": "nil"}, + "part2": {"plugin": "nil"}, + }, + ) + self.useFixture(self.snapcraft_yaml) + + self.provider_class_mock = mock.MagicMock() + self.provider_mock = mock.MagicMock() + self.provider_class_mock.return_value.__enter__.return_value = ( + self.provider_mock + ) + + self.fake_get_provider_for = fixtures.MockPatch( + "snapcraft_legacy.internal.build_providers.get_provider_for", + return_value=self.provider_class_mock, + ) + self.useFixture(self.fake_get_provider_for) + + def assert_clean_not_called(self): + self.fake_lifecycle_clean.mock.assert_not_called() + self.provider_mock.clean.assert_not_called() + self.provider_mock.clean_project.assert_not_called() + + +class StoreCommandsBaseTestCase(CommandBaseTestCase): + def setUp(self): + super().setUp() + self.fake_store = fixture_setup.FakeStore() + self.useFixture(self.fake_store) + self.client = storeapi.StoreClient() + + self.client.login(email="dummy", password="test correct password", ttl=1) + + +class FakeStoreCommandsBaseTestCase(CommandBaseTestCase): + def setUp(self): + super().setUp() + + # Our experimental environment variable is sticky + self.useFixture( + fixtures.EnvironmentVariable( + "SNAPCRAFT_EXPERIMENTAL_PROGRESSIVE_RELEASES", None + ) + ) + + self.fake_store_login = fixtures.MockPatchObject(storeapi.StoreClient, "login") + self.useFixture(self.fake_store_login) + + self.fake_store_logout = fixtures.MockPatchObject( + storeapi.StoreClient, "logout" + ) + self.useFixture(self.fake_store_logout) + + self.fake_store_register = fixtures.MockPatchObject( + storeapi._dashboard_api.DashboardAPI, "register" + ) + self.useFixture(self.fake_store_register) + + self.fake_store_account_info_data = { + "account_id": "abcd", + "account_keys": list(), + "snaps": { + "16": { + "snap-test": { + "snap-id": "snap-test-snap-id", + "status": "Approved", + "private": False, + "since": "2016-12-12T01:01Z", + "price": "0", + }, + "basic": { + "snap-id": "basic-snap-id", + "status": "Approved", + "private": False, + "since": "2016-12-12T01:01Z", + "price": "0", + }, + } + }, + } + + self.fake_store_account_info = fixtures.MockPatchObject( + storeapi._dashboard_api.DashboardAPI, + "get_account_information", + return_value=self.fake_store_account_info_data, + ) + self.useFixture(self.fake_store_account_info) + + self.fake_store_status = fixtures.MockPatchObject( + storeapi._dashboard_api.DashboardAPI, "snap_status", return_value=dict() + ) + self.useFixture(self.fake_store_status) + + self.fake_store_release = fixtures.MockPatchObject( + storeapi.StoreClient, "release" + ) + self.useFixture(self.fake_store_release) + + self.fake_store_register_key = fixtures.MockPatchObject( + storeapi._dashboard_api.DashboardAPI, "register_key" + ) + self.useFixture(self.fake_store_register_key) + + self.metrics = metrics.MetricsResults( + metrics=[ + metrics.MetricResults( + status=metrics.MetricsStatus["OK"], + snap_id="test-snap-id", + metric_name="daily_device_change", + buckets=["2021-01-01", "2021-01-02", "2021-01-03"], + series=[ + metrics.Series( + name="continued", + values=[10, 11, 12], + currently_released=None, + ), + metrics.Series( + name="lost", values=[1, 2, 3], currently_released=None + ), + metrics.Series( + name="new", values=[2, 3, 4], currently_released=None + ), + ], + ) + ] + ) + self.fake_store_get_metrics = fixtures.MockPatchObject( + storeapi.StoreClient, "get_metrics", return_value=self.metrics + ) + self.useFixture(self.fake_store_get_metrics) + + self.releases = Releases.unmarshal( + { + "revisions": [ + { + "architectures": ["i386"], + "base": "core20", + "build_url": None, + "confinement": "strict", + "created_at": " 2016-09-27T19:23:40Z", + "grade": "stable", + "revision": 2, + "sha3-384": "a9060ef4872ccacbfa440617a76fcd84967896b28d0d1eb7571f00a1098d766e7e93353b084ba6ad841d7b14b95ede48", + "size": 20, + "status": "Published", + "version": "2.0.1", + }, + { + "architectures": ["amd64"], + "base": "core20", + "build_url": None, + "confinement": "strict", + "created_at": "2016-09-27T18:38:43Z", + "grade": "stable", + "revision": 1, + "sha3-384": "a9060ef4872ccacbfa440617a76fcd84967896b28d0d1eb7571f00a1098d766e7e93353b084ba6ad841d7b14b95ede48", + "size": 20, + "status": "Published", + "version": "2.0.2", + }, + ], + "releases": [ + { + "architecture": "amd64", + "branch": None, + "channel": "latest/stable", + "expiration-date": None, + "revision": 1, + "risk": "stable", + "track": "latest", + "when": "2020-02-12T17:51:40.891996Z", + }, + { + "architecture": "i386", + "branch": None, + "channel": "latest/stable", + "expiration-date": None, + "revision": None, + "risk": "stable", + "track": "latest", + "when": "2020-02-11T17:51:40.891996Z", + }, + { + "architecture": "amd64", + "branch": None, + "channel": "latest/edge", + "expiration-date": None, + "revision": 1, + "risk": "stable", + "track": "latest", + "when": "2020-01-12T17:51:40.891996Z", + }, + ], + } + ) + self.fake_store_get_releases = fixtures.MockPatchObject( + storeapi.StoreClient, "get_snap_releases", return_value=self.releases + ) + self.useFixture(self.fake_store_get_releases) + + # Mock the snap command, pass through a select few. + self.fake_check_output = fixtures.MockPatch( + "subprocess.check_output", side_effect=mock_check_output + ) + self.useFixture(self.fake_check_output) + + # Pretend that the snap command is available + self.fake_package_installed = fixtures.MockPatch( + "snapcraft_legacy.internal.repo.Repo.is_package_installed", + return_value=True, + ) + self.useFixture(self.fake_package_installed) + + +class FakeResponse(requests.Response): + def __init__(self, content, status_code): + self._content = content + self.status_code = status_code + + @property + def content(self): + return self._content + + @property + def ok(self): + return self.status_code == 200 + + def json(self): + return json.loads(self._content) # type: ignore + + @property + def reason(self): + return self._content + + @property + def text(self): + return self.content + + +FAKE_UNAUTHORIZED_ERROR = craft_store.errors.StoreServerError( + FakeResponse(status_code=requests.codes.unauthorized, content="error") +) diff --git a/snapcraft/storeapi/http_clients/__init__.py b/tests/legacy/unit/commands/conftest.py similarity index 63% rename from snapcraft/storeapi/http_clients/__init__.py rename to tests/legacy/unit/commands/conftest.py index 0318e38608..e5f552e309 100644 --- a/snapcraft/storeapi/http_clients/__init__.py +++ b/tests/legacy/unit/commands/conftest.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright 2021 Canonical Ltd +# Copyright (C) 2017-2021 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,12 +14,20 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from typing import Union +from typing import List -from . import errors # noqa: F401 -from ._candid_client import CandidClient # noqa: F401 -from ._ubuntu_sso_client import UbuntuOneAuthClient # noqa: F401 -from ._http_client import Client # noqa: F401 +import pytest +from click.testing import CliRunner +from snapcraft_legacy.cli._runner import run -AuthClient = Union[CandidClient, UbuntuOneAuthClient] + +@pytest.fixture +def click_run(): + """Run commands using Click's testing backend.""" + cli = CliRunner() + + def runner(args: List[str]): + return cli.invoke(run, args) + + return runner diff --git a/tests/unit/commands/snapcraftctl/__init__.py b/tests/legacy/unit/commands/snapcraftctl/__init__.py similarity index 94% rename from tests/unit/commands/snapcraftctl/__init__.py rename to tests/legacy/unit/commands/snapcraftctl/__init__.py index 8e4c4cc247..358915bfd6 100644 --- a/tests/unit/commands/snapcraftctl/__init__.py +++ b/tests/legacy/unit/commands/snapcraftctl/__init__.py @@ -19,8 +19,8 @@ import fixtures from click.testing import CliRunner -from snapcraft.cli.snapcraftctl._runner import run -from tests import unit +from snapcraft_legacy.cli.snapcraftctl._runner import run +from tests.legacy import unit class CommandBaseNoFifoTestCase(unit.TestCase): diff --git a/tests/unit/commands/snapcraftctl/test_build.py b/tests/legacy/unit/commands/snapcraftctl/test_build.py similarity index 97% rename from tests/unit/commands/snapcraftctl/test_build.py rename to tests/legacy/unit/commands/snapcraftctl/test_build.py index 3f754f5186..a96089088d 100644 --- a/tests/unit/commands/snapcraftctl/test_build.py +++ b/tests/legacy/unit/commands/snapcraftctl/test_build.py @@ -18,7 +18,7 @@ from testtools.matchers import Contains, Equals, FileExists -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors from . import CommandBaseNoFifoTestCase, CommandBaseTestCase diff --git a/tests/unit/commands/snapcraftctl/test_set_grade.py b/tests/legacy/unit/commands/snapcraftctl/test_set_grade.py similarity index 98% rename from tests/unit/commands/snapcraftctl/test_set_grade.py rename to tests/legacy/unit/commands/snapcraftctl/test_set_grade.py index 1563508f0b..46ca203f05 100644 --- a/tests/unit/commands/snapcraftctl/test_set_grade.py +++ b/tests/legacy/unit/commands/snapcraftctl/test_set_grade.py @@ -18,7 +18,7 @@ from testtools.matchers import Contains, Equals, FileExists -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors from . import CommandBaseNoFifoTestCase, CommandBaseTestCase diff --git a/tests/unit/commands/snapcraftctl/test_set_version.py b/tests/legacy/unit/commands/snapcraftctl/test_set_version.py similarity index 98% rename from tests/unit/commands/snapcraftctl/test_set_version.py rename to tests/legacy/unit/commands/snapcraftctl/test_set_version.py index 0b30e27132..b264e76370 100644 --- a/tests/unit/commands/snapcraftctl/test_set_version.py +++ b/tests/legacy/unit/commands/snapcraftctl/test_set_version.py @@ -18,7 +18,7 @@ from testtools.matchers import Contains, Equals, FileExists -from snapcraft.internal import errors +from snapcraft_legacy.internal import errors from . import CommandBaseNoFifoTestCase, CommandBaseTestCase diff --git a/tests/unit/commands/test_build_providers.py b/tests/legacy/unit/commands/test_build_providers.py similarity index 92% rename from tests/unit/commands/test_build_providers.py rename to tests/legacy/unit/commands/test_build_providers.py index a01ec5dd25..15dbb90b0b 100644 --- a/tests/unit/commands/test_build_providers.py +++ b/tests/legacy/unit/commands/test_build_providers.py @@ -21,11 +21,11 @@ import fixtures from testtools.matchers import Equals -import snapcraft.yaml_utils.errors -from snapcraft.internal import steps -from snapcraft.internal.build_providers.errors import ProviderExecError -from tests import fixture_setup -from tests.unit.build_providers import ProviderImpl +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.build_providers.errors import ProviderExecError +from tests.legacy import fixture_setup +from tests.legacy.unit.build_providers import ProviderImpl from . import CommandBaseTestCase @@ -75,9 +75,9 @@ def setUp(self): # Don't actually run clean - we only want to test the command # line interface flag parsing. - self.useFixture(fixtures.MockPatch("snapcraft.internal.lifecycle.clean")) + self.useFixture(fixtures.MockPatch("snapcraft_legacy.internal.lifecycle.clean")) - # tests.unit.TestCase sets SNAPCRAFT_BUILD_ENVIRONMENT to host. + # tests.legacy.unit.TestCase sets SNAPCRAFT_BUILD_ENVIRONMENT to host. # These build provider tests will want to set this explicitly. self.useFixture( fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT", None) @@ -85,7 +85,7 @@ def setUp(self): self.mock_get_provider_for = self.useFixture( fixtures.MockPatch( - "snapcraft.internal.build_providers.get_provider_for", + "snapcraft_legacy.internal.build_providers.get_provider_for", return_value=ProviderImpl, ) ).mock @@ -93,7 +93,8 @@ def setUp(self): # Tests need to dictate this (or not). self.useFixture( fixtures.MockPatch( - "snapcraft.internal.common.is_process_container", return_value=False + "snapcraft_legacy.internal.common.is_process_container", + return_value=False, ) ) @@ -108,7 +109,8 @@ class AssortedBuildEnvironmentParsingTests(BuildEnvironmentParsingTest): def test_host_container(self): self.useFixture( fixtures.MockPatch( - "snapcraft.internal.common.is_process_container", return_value=True + "snapcraft_legacy.internal.common.is_process_container", + return_value=True, ) ) result = self.run_command([self.step]) @@ -242,7 +244,7 @@ def setUp(self): ) patcher = mock.patch( - "snapcraft.internal.build_providers.get_provider_for", + "snapcraft_legacy.internal.build_providers.get_provider_for", return_value=ProviderImpl, ) self.provider = patcher.start() @@ -267,7 +269,9 @@ def test_validation_fails(self): self.useFixture(snapcraft_yaml) self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, self.run_command, ["pull"] + snapcraft_legacy.yaml_utils.errors.YamlValidationError, + self.run_command, + ["pull"], ) @@ -300,7 +304,8 @@ def shell(self): shell_mock() patcher = mock.patch( - "snapcraft.internal.build_providers.get_provider_for", return_value=Provider + "snapcraft_legacy.internal.build_providers.get_provider_for", + return_value=Provider, ) self.provider = patcher.start() self.addCleanup(patcher.stop) @@ -352,7 +357,8 @@ def shell(self): shell_mock() patcher = mock.patch( - "snapcraft.internal.build_providers.get_provider_for", return_value=Provider + "snapcraft_legacy.internal.build_providers.get_provider_for", + return_value=Provider, ) self.provider = patcher.start() self.addCleanup(patcher.stop) @@ -456,7 +462,8 @@ def _mount_prime_directory(self) -> bool: return mount_prime_mock() patcher = mock.patch( - "snapcraft.internal.build_providers.get_provider_for", return_value=Provider + "snapcraft_legacy.internal.build_providers.get_provider_for", + return_value=Provider, ) self.provider = patcher.start() self.addCleanup(patcher.stop) @@ -505,7 +512,8 @@ def clean_parts(self, part_names): clean_mock(part_names=part_names) patcher = mock.patch( - "snapcraft.internal.build_providers.get_provider_for", return_value=Provider + "snapcraft_legacy.internal.build_providers.get_provider_for", + return_value=Provider, ) self.provider = patcher.start() self.addCleanup(patcher.stop) @@ -515,7 +523,7 @@ def clean_parts(self, part_names): self.make_snapcraft_yaml("pull", base="core20") - @mock.patch("snapcraft.internal.lifecycle.clean") + @mock.patch("snapcraft_legacy.internal.lifecycle.clean") def test_clean(self, lifecycle_clean_mock): result = self.run_command(["clean"]) @@ -545,8 +553,8 @@ def test_unprime_with_build_environment_errors(self): self.clean_project_mock.assert_not_called() self.clean_mock.assert_not_called() - @mock.patch("snapcraft.cli.lifecycle.get_project") - @mock.patch("snapcraft.internal.lifecycle.clean") + @mock.patch("snapcraft_legacy.cli.lifecycle.get_project") + @mock.patch("snapcraft_legacy.internal.lifecycle.clean") def test_unprime_in_managed_host(self, lifecycle_clean_mock, get_project_mock): self.useFixture( fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT", "managed-host") diff --git a/tests/unit/commands/test_clean.py b/tests/legacy/unit/commands/test_clean.py similarity index 98% rename from tests/unit/commands/test_clean.py rename to tests/legacy/unit/commands/test_clean.py index 08be3f633f..fe63441c7d 100644 --- a/tests/unit/commands/test_clean.py +++ b/tests/legacy/unit/commands/test_clean.py @@ -18,7 +18,7 @@ from testtools.matchers import Equals -from snapcraft.internal import steps +from snapcraft_legacy.internal import steps from . import LifecycleCommandsBaseTestCase diff --git a/tests/unit/commands/test_create_key.py b/tests/legacy/unit/commands/test_create_key.py similarity index 98% rename from tests/unit/commands/test_create_key.py rename to tests/legacy/unit/commands/test_create_key.py index bed827ee3d..c9619e485f 100644 --- a/tests/unit/commands/test_create_key.py +++ b/tests/legacy/unit/commands/test_create_key.py @@ -17,7 +17,7 @@ import fixtures from testtools.matchers import Equals -from snapcraft import storeapi +from snapcraft_legacy import storeapi from . import FakeStoreCommandsBaseTestCase, get_sample_key diff --git a/tests/unit/commands/test_edit_validation_sets.py b/tests/legacy/unit/commands/test_edit_validation_sets.py similarity index 95% rename from tests/unit/commands/test_edit_validation_sets.py rename to tests/legacy/unit/commands/test_edit_validation_sets.py index 9ea4f44f13..76384d4c23 100644 --- a/tests/unit/commands/test_edit_validation_sets.py +++ b/tests/legacy/unit/commands/test_edit_validation_sets.py @@ -15,13 +15,13 @@ # along with this program. If not, see . import json -from typing import Dict, Any +from typing import Any, Dict from unittest import mock import pytest -from snapcraft.storeapi.v2 import validation_sets -from snapcraft.storeapi import StoreClient +from snapcraft_legacy.storeapi import StoreClient +from snapcraft_legacy.storeapi.v2 import validation_sets @pytest.fixture @@ -93,12 +93,13 @@ def sign(assertion: Dict[str, Any], *, key_name: str) -> bytes: return (json.dumps(assertion) + f"\n\nSIGNED{key_name}").encode() patched_snap_sign = mock.patch( - "snapcraft.cli.assertions._sign_assertion", side_effect=sign + "snapcraft_legacy.cli.assertions._sign_assertion", side_effect=sign ) yield patched_snap_sign.start() patched_snap_sign.stop() +@pytest.mark.usefixtures("memory_keyring") @pytest.mark.usefixtures("mock_subprocess_run") def test_edit_validation_sets_with_no_changes_to_existing_set( click_run, @@ -129,12 +130,13 @@ def test_edit_validation_sets_with_no_changes_to_existing_set( @pytest.fixture def fake_edit_validation_sets(): patched_edit_validation_sets = mock.patch( - "snapcraft.cli.assertions._edit_validation_sets" + "snapcraft_legacy.cli.assertions._edit_validation_sets" ) yield patched_edit_validation_sets.start() patched_edit_validation_sets.stop() +@pytest.mark.usefixtures("memory_keyring") @pytest.mark.parametrize("key_name", [None, "general", "main"]) def test_edit_validation_sets_with_changes_to_existing_set( click_run, diff --git a/tests/unit/commands/test_extensions.py b/tests/legacy/unit/commands/test_extensions.py similarity index 97% rename from tests/unit/commands/test_extensions.py rename to tests/legacy/unit/commands/test_extensions.py index 299b6897f8..b8b1610ada 100644 --- a/tests/unit/commands/test_extensions.py +++ b/tests/legacy/unit/commands/test_extensions.py @@ -20,9 +20,9 @@ from testtools.matchers import Equals -from snapcraft.internal.project_loader import errors, supported_extension_names -from snapcraft.internal.project_loader._extensions._extension import Extension -from tests import fixture_setup +from snapcraft_legacy.internal.project_loader import errors, supported_extension_names +from snapcraft_legacy.internal.project_loader._extensions._extension import Extension +from tests.legacy import fixture_setup from . import CommandBaseTestCase diff --git a/tests/unit/commands/test_gated.py b/tests/legacy/unit/commands/test_gated.py similarity index 86% rename from tests/unit/commands/test_gated.py rename to tests/legacy/unit/commands/test_gated.py index 722c0da17d..a7a355cfce 100644 --- a/tests/unit/commands/test_gated.py +++ b/tests/legacy/unit/commands/test_gated.py @@ -18,7 +18,7 @@ from testtools.matchers import Contains, Equals -import snapcraft.storeapi.errors +import snapcraft_legacy.storeapi.errors from . import StoreCommandsBaseTestCase @@ -27,10 +27,8 @@ class GatedCommandTestCase(StoreCommandsBaseTestCase): def test_gated_unknown_snap(self): - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - snapcraft.storeapi.errors.SnapNotFoundError, + snapcraft_legacy.storeapi.errors.SnapNotFoundError, self.run_command, ["gated", "notfound"], ) @@ -38,8 +36,6 @@ def test_gated_unknown_snap(self): self.assertThat(str(raised), Equals("Snap 'notfound' was not found.")) def test_gated_success(self): - self.client.login(email="dummy", password="test correct password") - result = self.run_command(["gated", "core"]) self.assertThat(result.exit_code, Equals(0)) @@ -53,8 +49,6 @@ def test_gated_success(self): self.assertThat(result.output, Contains(expected_output)) def test_gated_no_validations(self): - self.client.login(email="dummy", password="test correct password") - result = self.run_command(["gated", "test-snap-with-no-validations"]) self.assertThat(result.exit_code, Equals(0)) diff --git a/tests/unit/commands/test_help.py b/tests/legacy/unit/commands/test_help.py similarity index 95% rename from tests/unit/commands/test_help.py rename to tests/legacy/unit/commands/test_help.py index 7d76fc8f43..9a2e532533 100644 --- a/tests/unit/commands/test_help.py +++ b/tests/legacy/unit/commands/test_help.py @@ -21,9 +21,9 @@ import fixtures from testtools.matchers import Contains, Equals, StartsWith -from snapcraft.cli._runner import run -from snapcraft.cli.help import _TOPICS -from tests import fixture_setup +from snapcraft_legacy.cli._runner import run +from snapcraft_legacy.cli.help import _TOPICS +from tests.legacy import fixture_setup from . import CommandBaseTestCase @@ -115,7 +115,9 @@ def test_print_module_named_with_dashes_help_for_valid_plugin(self): def test_show_module_help_with_devel_for_valid_plugin(self): result = self.run_command(["help", "nil", "--devel"]) - expected = "Help on module snapcraft.plugins.v2.nil in snapcraft.plugins" + expected = ( + "Help on module snapcraft_legacy.plugins.v2.nil in snapcraft_legacy.plugins" + ) output = result.output[: len(expected)] self.assertThat( @@ -164,9 +166,9 @@ def test_no_unicode_in_help_strings(self): import os from pathlib import Path - import snapcraft.plugins + import snapcraft_legacy.plugins - for plugin in Path(snapcraft.plugins.__path__[0]).glob("*.py"): + for plugin in Path(snapcraft_legacy.plugins.__path__[0]).glob("*.py"): if os.path.isfile(str(plugin)) and not os.path.basename( str(plugin) ).startswith("_"): diff --git a/tests/unit/commands/test_init.py b/tests/legacy/unit/commands/test_init.py similarity index 96% rename from tests/unit/commands/test_init.py rename to tests/legacy/unit/commands/test_init.py index 05bfe3f3fa..301dcd12f5 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/legacy/unit/commands/test_init.py @@ -18,7 +18,7 @@ from testtools.matchers import Contains, Equals, FileContains -import snapcraft.internal.errors +import snapcraft_legacy.internal.errors from . import CommandBaseTestCase @@ -75,7 +75,7 @@ def test_init_with_existing_yaml(self): open(yaml_path, "w").close() raised = self.assertRaises( - snapcraft.internal.errors.SnapcraftEnvironmentError, + snapcraft_legacy.internal.errors.SnapcraftEnvironmentError, self.run_command, ["init"], ) diff --git a/tests/unit/commands/test_list_keys.py b/tests/legacy/unit/commands/test_list_keys.py similarity index 96% rename from tests/unit/commands/test_list_keys.py rename to tests/legacy/unit/commands/test_list_keys.py index 2c454a0352..cb808b5641 100644 --- a/tests/unit/commands/test_list_keys.py +++ b/tests/legacy/unit/commands/test_list_keys.py @@ -16,12 +16,10 @@ from textwrap import dedent -from testtools.matchers import Contains, Equals import fixtures +from testtools.matchers import Contains, Equals -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase, get_sample_key +from . import FAKE_UNAUTHORIZED_ERROR, FakeStoreCommandsBaseTestCase, get_sample_key class ListKeysCommandTestCase(FakeStoreCommandsBaseTestCase): @@ -29,9 +27,9 @@ class ListKeysCommandTestCase(FakeStoreCommandsBaseTestCase): command_name = "list-keys" def test_command_without_login_must_ask(self): - # TODO: look into why this many calls are done inside snapcraft.storeapi + # TODO: look into why this many calls are done inside snapcraft_legacy.storeapi self.fake_store_account_info.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), + FAKE_UNAUTHORIZED_ERROR, {"account_id": "abcd", "account_keys": list()}, {"account_id": "abcd", "account_keys": list()}, {"account_id": "abcd", "account_keys": list()}, diff --git a/tests/unit/commands/test_list_plugins.py b/tests/legacy/unit/commands/test_list_plugins.py similarity index 92% rename from tests/unit/commands/test_list_plugins.py rename to tests/legacy/unit/commands/test_list_plugins.py index 62f1e90f78..6d0ebfad82 100644 --- a/tests/unit/commands/test_list_plugins.py +++ b/tests/legacy/unit/commands/test_list_plugins.py @@ -17,8 +17,8 @@ import fixtures from testtools.matchers import Contains, Equals -import snapcraft -from tests import fixture_setup +import snapcraft_legacy +from tests.legacy import fixture_setup from . import CommandBaseTestCase @@ -89,7 +89,7 @@ def test_default_from_snapcraft_yaml(self): result.output, Contains("Displaying plugins available for 'core18") ) self.fake_iter_modules.mock.assert_called_once_with( - snapcraft.plugins.v1.__path__ + snapcraft_legacy.plugins.v1.__path__ ) def test_alias(self): @@ -115,15 +115,15 @@ def test_core20_list(self): ) self.fake_iter_modules.mock.assert_called_once_with( - snapcraft.plugins.v2.__path__ + snapcraft_legacy.plugins.v2.__path__ ) def test_core2y_list(self): # Note that core2y is some future base, _not_ allowed to be used from cmdline # This tests that addition of the next base will use the latest version of plugins - snapcraft.cli.discovery.list_plugins.callback("core2y") + snapcraft_legacy.cli.discovery.list_plugins.callback("core2y") self.fake_iter_modules.mock.assert_called_once_with( - snapcraft.plugins.v2.__path__ + snapcraft_legacy.plugins.v2.__path__ ) def test_list_plugins_non_tty(self): @@ -135,7 +135,7 @@ def test_list_plugins_non_tty(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat(result.output, Contains(self.default_plugin_output)) self.fake_iter_modules.mock.assert_called_once_with( - snapcraft.plugins.v1.__path__ + snapcraft_legacy.plugins.v1.__path__ ) def test_list_plugins_large_terminal(self): @@ -147,7 +147,7 @@ def test_list_plugins_large_terminal(self): self.assertThat(result.exit_code, Equals(0)) self.assertThat(result.output, Contains(self.default_plugin_output)) self.fake_iter_modules.mock.assert_called_once_with( - snapcraft.plugins.v1.__path__ + snapcraft_legacy.plugins.v1.__path__ ) def test_list_plugins_small_terminal(self): @@ -169,5 +169,5 @@ def test_list_plugins_small_terminal(self): output_slice = [o.strip() for o in result.output.splitlines()][1:] self.assertThat(output_slice, Equals(expected_output)) self.fake_iter_modules.mock.assert_called_once_with( - snapcraft.plugins.v1.__path__ + snapcraft_legacy.plugins.v1.__path__ ) diff --git a/tests/unit/commands/test_list_revisions.py b/tests/legacy/unit/commands/test_list_revisions.py similarity index 95% rename from tests/unit/commands/test_list_revisions.py rename to tests/legacy/unit/commands/test_list_revisions.py index ed095a0c7d..82ad631269 100644 --- a/tests/unit/commands/test_list_revisions.py +++ b/tests/legacy/unit/commands/test_list_revisions.py @@ -20,9 +20,7 @@ import fixtures from testtools.matchers import Contains, Equals -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase +from . import FAKE_UNAUTHORIZED_ERROR, FakeStoreCommandsBaseTestCase class RevisionsCommandTestCase(FakeStoreCommandsBaseTestCase): @@ -37,7 +35,7 @@ def test_revisions_without_snap_raises_exception(self): def test_revisions_without_login_must_ask(self): self.fake_store_get_releases.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), + FAKE_UNAUTHORIZED_ERROR, self.releases, ] diff --git a/tests/unit/commands/test_list_validation_sets.py b/tests/legacy/unit/commands/test_list_validation_sets.py similarity index 96% rename from tests/unit/commands/test_list_validation_sets.py rename to tests/legacy/unit/commands/test_list_validation_sets.py index 8464e16f52..8901139971 100644 --- a/tests/unit/commands/test_list_validation_sets.py +++ b/tests/legacy/unit/commands/test_list_validation_sets.py @@ -19,8 +19,8 @@ import pytest -from snapcraft.storeapi.v2 import validation_sets -from snapcraft.storeapi import StoreClient +from snapcraft_legacy.storeapi import StoreClient +from snapcraft_legacy.storeapi.v2 import validation_sets @pytest.fixture @@ -58,6 +58,7 @@ def fake_dashboard_get_validation_sets(): ] +@pytest.mark.usefixtures("memory_keyring") @pytest.mark.parametrize("combo,", combinations) def test_no_sets(click_run, fake_dashboard_get_validation_sets, combo): cmd = ["list-validation-sets"] @@ -75,6 +76,7 @@ def test_no_sets(click_run, fake_dashboard_get_validation_sets, combo): ) +@pytest.mark.usefixtures("memory_keyring") def test_list_validation_sets(click_run, fake_dashboard_get_validation_sets): fake_dashboard_get_validation_sets.return_value = validation_sets.ValidationSets.unmarshal( { diff --git a/tests/unit/commands/test_metrics.py b/tests/legacy/unit/commands/test_metrics.py similarity index 96% rename from tests/unit/commands/test_metrics.py rename to tests/legacy/unit/commands/test_metrics.py index b4dffb436e..146d692794 100644 --- a/tests/unit/commands/test_metrics.py +++ b/tests/legacy/unit/commands/test_metrics.py @@ -18,9 +18,7 @@ import pytest -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase +from . import FAKE_UNAUTHORIZED_ERROR, FakeStoreCommandsBaseTestCase class MetricsCommandTestCase(FakeStoreCommandsBaseTestCase): @@ -49,7 +47,7 @@ def test_metrics_without_format_raises_exception(self): @pytest.mark.skip("needs more work") def test_status_without_login_must_ask(self): self.fake_store_account_info.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), + FAKE_UNAUTHORIZED_ERROR, self.fake_store_account_info_data, ] diff --git a/tests/unit/commands/test_pack.py b/tests/legacy/unit/commands/test_pack.py similarity index 100% rename from tests/unit/commands/test_pack.py rename to tests/legacy/unit/commands/test_pack.py diff --git a/tests/unit/commands/test_promote.py b/tests/legacy/unit/commands/test_promote.py similarity index 100% rename from tests/unit/commands/test_promote.py rename to tests/legacy/unit/commands/test_promote.py diff --git a/tests/unit/commands/test_pull_build_stage_prime.py b/tests/legacy/unit/commands/test_pull_build_stage_prime.py similarity index 99% rename from tests/unit/commands/test_pull_build_stage_prime.py rename to tests/legacy/unit/commands/test_pull_build_stage_prime.py index d127d4be7d..acb54ee65a 100644 --- a/tests/unit/commands/test_pull_build_stage_prime.py +++ b/tests/legacy/unit/commands/test_pull_build_stage_prime.py @@ -18,7 +18,7 @@ from testtools.matchers import Equals -from snapcraft.internal import steps +from snapcraft_legacy.internal import steps from . import LifecycleCommandsBaseTestCase diff --git a/tests/unit/commands/test_refresh.py b/tests/legacy/unit/commands/test_refresh.py similarity index 93% rename from tests/unit/commands/test_refresh.py rename to tests/legacy/unit/commands/test_refresh.py index a1a24145b3..6d8dee82a7 100644 --- a/tests/unit/commands/test_refresh.py +++ b/tests/legacy/unit/commands/test_refresh.py @@ -19,7 +19,7 @@ from testtools.matchers import Equals -from tests.unit import TestWithFakeRemoteParts +from tests.legacy.unit import TestWithFakeRemoteParts from . import CommandBaseTestCase @@ -51,7 +51,7 @@ def make_snapcraft_yaml(self, n=1, snap_type="app", snapcraft_yaml=None): class RefreshCommandTestCase(RefreshCommandBaseTestCase): - @mock.patch("snapcraft.cli.containers.repo.Repo.refresh_build_packages") + @mock.patch("snapcraft_legacy.cli.containers.repo.Repo.refresh_build_packages") def test_refresh(self, mock_repo_refresh): self.make_snapcraft_yaml() diff --git a/tests/unit/commands/test_register_key.py b/tests/legacy/unit/commands/test_register_key.py similarity index 88% rename from tests/unit/commands/test_register_key.py rename to tests/legacy/unit/commands/test_register_key.py index b17457e371..4a4fe28404 100644 --- a/tests/unit/commands/test_register_key.py +++ b/tests/legacy/unit/commands/test_register_key.py @@ -22,7 +22,7 @@ from simplejson.scanner import JSONDecodeError from testtools.matchers import Contains, Equals -from snapcraft import storeapi +from snapcraft_legacy import storeapi from . import FakeStoreCommandsBaseTestCase, get_sample_key @@ -48,9 +48,8 @@ def test_register_key(self): acls=["modify_account_key"], packages=None, channels=None, - expires=None, - save=False, - config_fd=None, + # one day + ttl=86400, ) self.fake_store_register_key.mock.call_once_with( dedent( @@ -83,22 +82,6 @@ def test_register_key_no_keys_with_name(self): str(raised), Contains("You have no usable key named 'nonexistent'") ) - def test_register_key_login_failed(self): - self.fake_store_login.mock.side_effect = ( - storeapi.http_clients.errors.InvalidCredentialsError("error") - ) - - raised = self.assertRaises( - storeapi.http_clients.errors.InvalidCredentialsError, - self.run_command, - ["register-key", "default"], - input="user@example.com\nsecret\n", - ) - - assert ( - str(raised) == 'Invalid credentials: error. Have you run "snapcraft login"?' - ) - def test_register_key_account_info_failed(self): response = mock.Mock() response.json.side_effect = JSONDecodeError("mock-fail", "doc", 1) @@ -109,7 +92,9 @@ def test_register_key_account_info_failed(self): ) # Fake the login check - self.useFixture(fixtures.MockPatch("snapcraft._store.login", return_value=True)) + self.useFixture( + fixtures.MockPatch("snapcraft_legacy._store.login", return_value=True) + ) raised = self.assertRaises( storeapi.errors.StoreAccountInformationError, diff --git a/tests/unit/commands/test_remote.py b/tests/legacy/unit/commands/test_remote.py similarity index 90% rename from tests/unit/commands/test_remote.py rename to tests/legacy/unit/commands/test_remote.py index bb8a15a6ad..022a27938f 100644 --- a/tests/unit/commands/test_remote.py +++ b/tests/legacy/unit/commands/test_remote.py @@ -19,9 +19,9 @@ import fixtures from testtools.matchers import Contains, Equals -import snapcraft.internal.remote_build.errors as errors -import snapcraft.project -from tests import fixture_setup +import snapcraft_legacy.internal.remote_build.errors as errors +import snapcraft_legacy.project +from tests.legacy import fixture_setup from . import CommandBaseTestCase @@ -36,7 +36,9 @@ def setUp(self): self.useFixture(self.snapcraft_yaml) self.mock_lc_init = self.useFixture( - fixtures.MockPatch("snapcraft.cli.remote.LaunchpadClient", autospec=True) + fixtures.MockPatch( + "snapcraft_legacy.cli.remote.LaunchpadClient", autospec=True + ) ).mock self.mock_lc = self.mock_lc_init.return_value self.mock_lc_architectures = mock.PropertyMock(return_value=["i386"]) @@ -45,13 +47,13 @@ def setUp(self): self.mock_project = self.useFixture( fixtures.MockPatchObject( - snapcraft.project.Project, + snapcraft_legacy.project.Project, "_get_project_directory_hash", return_value="fakehash123", ) ) - @mock.patch("snapcraft.cli.remote.echo.confirm") + @mock.patch("snapcraft_legacy.cli.remote.echo.confirm") def test_remote_build_prompts(self, mock_confirm): result = self.run_command(["remote-build"]) @@ -70,7 +72,7 @@ def test_remote_build_prompts(self, mock_confirm): default=True, ) - @mock.patch("snapcraft.cli.remote.echo.confirm") + @mock.patch("snapcraft_legacy.cli.remote.echo.confirm") def test_remote_build_with_accept_option_doesnt_prompt(self, mock_confirm): result = self.run_command(["remote-build", "--launchpad-accept-public-upload"]) @@ -80,7 +82,7 @@ def test_remote_build_with_accept_option_doesnt_prompt(self, mock_confirm): self.assertThat(result.exit_code, Equals(0)) mock_confirm.assert_not_called() - @mock.patch("snapcraft.cli.remote.echo.confirm") + @mock.patch("snapcraft_legacy.cli.remote.echo.confirm") def test_remote_build_without_acceptance_raises(self, mock_confirm): mock_confirm.return_value = False self.assertRaises( @@ -137,7 +139,7 @@ def test_remote_build_invalid_user_arch(self): self.mock_lc.start_build.assert_not_called() self.mock_lc.cleanup.assert_not_called() - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_sudo_errors(self, mock_echo): self.useFixture(fixtures.EnvironmentVariable("SUDO_USER", "testuser")) self.useFixture(fixtures.MockPatch("os.geteuid", return_value=0)) @@ -151,7 +153,7 @@ def test_remote_build_sudo_errors(self, mock_echo): ] ) - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_recover_doesnt_prompt(self, mock_echo): result = self.run_command(["remote-build", "--recover"]) @@ -160,7 +162,7 @@ def test_remote_build_recover_doesnt_prompt(self, mock_echo): mock_echo.info.assert_called_with("No build found.") mock_echo.confirm.assert_not_called() - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_status_doesnt_prompt(self, mock_echo): result = self.run_command(["remote-build", "--status"]) @@ -169,7 +171,7 @@ def test_remote_build_status_doesnt_prompt(self, mock_echo): mock_echo.info.assert_called_with("No build found.") mock_echo.confirm.assert_not_called() - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_recover_uses_calculated_hash(self, mock_echo): result = self.run_command( ["remote-build", "--launchpad-accept-public-upload", "--recover"] @@ -182,7 +184,7 @@ def test_remote_build_recover_uses_calculated_hash(self, mock_echo): build_id="snapcraft-test-snap-fakehash123", ) - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_recover_uses_build_id(self, mock_echo): result = self.run_command( [ @@ -201,7 +203,7 @@ def test_remote_build_recover_uses_build_id(self, mock_echo): build_id="snapcraft-test-snap-foo", ) - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_status_uses_calculated_hash(self, mock_echo): result = self.run_command( ["remote-build", "--launchpad-accept-public-upload", "--status"] @@ -214,7 +216,7 @@ def test_remote_build_status_uses_calculated_hash(self, mock_echo): build_id="snapcraft-test-snap-fakehash123", ) - @mock.patch("snapcraft.cli.remote.echo") + @mock.patch("snapcraft_legacy.cli.remote.echo") def test_remote_build_status_uses_build_id(self, mock_echo): result = self.run_command( [ diff --git a/tests/unit/commands/test_set_default_track.py b/tests/legacy/unit/commands/test_set_default_track.py similarity index 68% rename from tests/unit/commands/test_set_default_track.py rename to tests/legacy/unit/commands/test_set_default_track.py index 37f22173c6..6a04e177e4 100644 --- a/tests/unit/commands/test_set_default_track.py +++ b/tests/legacy/unit/commands/test_set_default_track.py @@ -17,10 +17,9 @@ import fixtures from testtools.matchers import Contains, Equals -import snapcraft -from snapcraft import storeapi +from snapcraft_legacy import storeapi -from . import FakeStoreCommandsBaseTestCase +from . import FAKE_UNAUTHORIZED_ERROR, FakeStoreCommandsBaseTestCase class SetDefaultTrackCommandTestCase(FakeStoreCommandsBaseTestCase): @@ -40,7 +39,7 @@ def test_set_default_track_without_snap_raises_exception(self): def test_set_default_track_without_login_must_ask(self): self.fake_metadata.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), + FAKE_UNAUTHORIZED_ERROR, None, ] @@ -59,20 +58,3 @@ def test_set_default_track(self): self.fake_metadata.mock.assert_called_once_with( snap_name="snap-test", metadata=dict(default_track="2.0"), force=True ) - - def test_invalid_track_fails(self): - mock_wrap = self.useFixture( - fixtures.MockPatch( - "snapcraft.cli.echo.exit_error", wraps=snapcraft.cli.echo.exit_error - ) - ).mock - - result = self.run_command(["set-default-track", "snap-test", "3.0"]) - - self.assertThat(result.exit_code, Equals(2)) - self.assertThat(result.output, Contains("'2.0', 'latest'")) - mock_wrap.assert_called_once_with( - brief="The specified track '3.0' does not exist for 'snap-test'.", - details="Valid tracks for 'snap-test': '2.0', 'latest'.", - resolution="Ensure the '3.0' track exists for the 'snap-test' snap and try again.", - ) diff --git a/tests/unit/commands/test_sign_build.py b/tests/legacy/unit/commands/test_sign_build.py similarity index 78% rename from tests/unit/commands/test_sign_build.py rename to tests/legacy/unit/commands/test_sign_build.py index 7d1c56787c..c02078d901 100644 --- a/tests/unit/commands/test_sign_build.py +++ b/tests/legacy/unit/commands/test_sign_build.py @@ -15,16 +15,15 @@ # along with this program. If not, see . import os import shutil -import subprocess from unittest import mock import fixtures from testtools.matchers import Contains, Equals, FileExists, Not -import tests -from snapcraft import internal, storeapi +import tests.legacy +from snapcraft_legacy import internal, storeapi -from . import CommandBaseTestCase +from . import FakeStoreCommandsBaseTestCase, mock_check_output class SnapTest(fixtures.TempDir): @@ -34,7 +33,7 @@ class SnapTest(fixtures.TempDir): gets cleaned up automatically. """ - data_dir = os.path.join(os.path.dirname(tests.__file__), "data") + data_dir = os.path.join(os.path.dirname(tests.legacy.__file__), "data") def __init__(self, test_snap_name): super(SnapTest, self).__init__() @@ -47,7 +46,7 @@ def _setUp(self): shutil.copyfile(test_snap_path, self.snap_path) -class SignBuildTestCase(CommandBaseTestCase): +class SignBuildTestCase(FakeStoreCommandsBaseTestCase): def setUp(self): super().setUp() self.snap_test = SnapTest("test-snap.snap") @@ -66,7 +65,7 @@ def test_sign_build_nonexisting_snap(self): def test_sign_build_invalid_snap(self): snap_path = os.path.join( - os.path.dirname(tests.__file__), "data", "invalid.snap" + os.path.dirname(tests.legacy.__file__), "data", "invalid.snap" ) raised = self.assertRaises( @@ -77,20 +76,18 @@ def test_sign_build_invalid_snap(self): self.assertThat(str(raised), Contains("Cannot read data from snap")) - @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_missing_account_info( self, mock_get_snap_data, - mock_get_account_info, ): - mock_get_account_info.return_value = {"account_id": "abcd", "snaps": {}} mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} raised = self.assertRaises( storeapi.errors.StoreBuildAssertionPermissionError, self.run_command, ["sign-build", self.snap_test.snap_path], + input="1\n", ) self.assertThat( @@ -102,18 +99,12 @@ def test_sign_build_missing_account_info( ), ) - @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_no_usable_keys( self, mock_get_snap_data, - mock_get_account_info, ): - mock_get_account_info.return_value = { - "account_id": "abcd", - "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, - } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} + mock_get_snap_data.return_value = {"name": "snap-test", "grade": "stable"} self.useFixture( fixtures.MockPatch("subprocess.check_output", return_value="[]".encode()) @@ -137,7 +128,7 @@ def test_sign_build_no_usable_keys( self.assertThat(snap_build_path, Not(FileExists())) @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_no_usable_named_key( self, mock_get_snap_data, @@ -170,7 +161,7 @@ def test_sign_build_no_usable_named_key( self.assertThat(snap_build_path, Not(FileExists())) @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_unregistered_key( self, mock_get_snap_data, @@ -209,47 +200,7 @@ def test_sign_build_unregistered_key( self.assertThat(snap_build_path, Not(FileExists())) @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") - def test_sign_build_snapd_failure( - self, - mock_get_snap_data, - mock_get_account_info, - ): - mock_get_account_info.return_value = { - "account_id": "abcd", - "account_keys": [{"public-key-sha3-384": "a_hash"}], - "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, - } - mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} - self.useFixture( - fixtures.MockPatch( - "subprocess.check_output", - side_effect=[ - '[{"name": "default", "sha3-384": "a_hash"}]'.encode(), - subprocess.CalledProcessError(1, ["a", "b"]), - ], - ) - ) - - raised = self.assertRaises( - storeapi.errors.SignBuildAssertionError, - self.run_command, - ["sign-build", self.snap_test.snap_path], - ) - - self.assertThat( - str(raised), - Contains( - "Failed to sign build assertion for {!r}".format( - self.snap_test.snap_path - ) - ), - ) - snap_build_path = self.snap_test.snap_path + "-build" - self.assertThat(snap_build_path, Not(FileExists())) - - @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_locally_successfully( self, mock_get_snap_data, @@ -262,11 +213,13 @@ def test_sign_build_locally_successfully( mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} fake_check_output = fixtures.MockPatch( "subprocess.check_output", - side_effect=['[{"name": "default"}]'.encode(), b"Mocked assertion"], + side_effect=mock_check_output, ) self.useFixture(fake_check_output) - result = self.run_command(["sign-build", self.snap_test.snap_path, "--local"]) + result = self.run_command( + ["sign-build", self.snap_test.snap_path, "--local"], input="1\n" + ) self.assertThat(result.exit_code, Equals(0)) snap_build_path = self.snap_test.snap_path + "-build" @@ -289,7 +242,7 @@ def test_sign_build_locally_successfully( ) @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_missing_grade( self, mock_get_snap_data, @@ -302,12 +255,13 @@ def test_sign_build_missing_grade( } mock_get_snap_data.return_value = {"name": "test-snap"} fake_check_output = fixtures.MockPatch( - "subprocess.check_output", - side_effect=['[{"name": "default"}]'.encode(), b"Mocked assertion"], + "subprocess.check_output", side_effect=mock_check_output ) self.useFixture(fake_check_output) - result = self.run_command(["sign-build", self.snap_test.snap_path, "--local"]) + result = self.run_command( + ["sign-build", self.snap_test.snap_path, "--local"], input="1\n" + ) self.assertThat(result.exit_code, Equals(0)) snap_build_path = self.snap_test.snap_path + "-build" @@ -331,7 +285,7 @@ def test_sign_build_missing_grade( @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "push_snap_build") @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_upload_successfully( self, mock_get_snap_data, @@ -340,20 +294,21 @@ def test_sign_build_upload_successfully( ): mock_get_account_info.return_value = { "account_id": "abcd", - "account_keys": [{"public-key-sha3-384": "a_hash"}], + "account_keys": [ + { + "public-key-sha3-384": "vdEeQvRxmZ26npJCFaGnl-VfGz0lU2jZZkWp_s7E-RxVCNtH2_mtjcxq2NkDKkIp" + } + ], "snaps": {"16": {"test-snap": {"snap-id": "snap-id"}}}, } mock_get_snap_data.return_value = {"name": "test-snap", "grade": "stable"} fake_check_output = fixtures.MockPatch( "subprocess.check_output", - side_effect=[ - '[{"name": "default", "sha3-384": "a_hash"}]'.encode(), - b"Mocked assertion", - ], + side_effect=mock_check_output, ) self.useFixture(fake_check_output) - result = self.run_command(["sign-build", self.snap_test.snap_path]) + result = self.run_command(["sign-build", self.snap_test.snap_path], input="1\n") self.assertThat(result.exit_code, Equals(0)) snap_build_path = self.snap_test.snap_path + "-build" @@ -384,7 +339,7 @@ def test_sign_build_upload_successfully( @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "push_snap_build") @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get_account_information") - @mock.patch("snapcraft._store._get_data_from_snap_file") + @mock.patch("snapcraft_legacy._store.get_data_from_snap_file") def test_sign_build_upload_existing( self, mock_get_snap_data, diff --git a/tests/unit/commands/test_snap.py b/tests/legacy/unit/commands/test_snap.py similarity index 99% rename from tests/unit/commands/test_snap.py rename to tests/legacy/unit/commands/test_snap.py index 71a93a2435..365033c29f 100644 --- a/tests/unit/commands/test_snap.py +++ b/tests/legacy/unit/commands/test_snap.py @@ -20,7 +20,7 @@ from testtools.matchers import Contains, Equals -from snapcraft.internal import steps +from snapcraft_legacy.internal import steps from . import LifecycleCommandsBaseTestCase diff --git a/tests/unit/commands/test_upload_metadata.py b/tests/legacy/unit/commands/test_upload_metadata.py similarity index 88% rename from tests/unit/commands/test_upload_metadata.py rename to tests/legacy/unit/commands/test_upload_metadata.py index 62e909b432..aabc4f09c5 100644 --- a/tests/unit/commands/test_upload_metadata.py +++ b/tests/legacy/unit/commands/test_upload_metadata.py @@ -21,11 +21,11 @@ import fixtures from testtools.matchers import Contains, Equals, Not -import tests -from snapcraft import storeapi -from snapcraft.storeapi.errors import StoreUploadError +import tests.legacy +from snapcraft_legacy import storeapi +from snapcraft_legacy.storeapi.errors import StoreUploadError -from . import CommandBaseTestCase +from . import FAKE_UNAUTHORIZED_ERROR, CommandBaseTestCase class UploadMetadataCommandTestCase(CommandBaseTestCase): @@ -33,7 +33,7 @@ def setUp(self): super().setUp() self.fake_precheck = fixtures.MockPatch( - "snapcraft.storeapi.StoreClient.upload_precheck" + "snapcraft_legacy.storeapi.StoreClient.upload_precheck" ) self.useFixture(self.fake_precheck) @@ -55,7 +55,7 @@ def _save_updated_icon(snap_name, metadata, force): self.useFixture(self.fake_binary_metadata) self.snap_file = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap-with-icon.snap" + os.path.dirname(tests.legacy.__file__), "data", "test-snap-with-icon.snap" ) def assert_expected_metadata_calls(self, force=False, optional_text_metadata=None): @@ -89,7 +89,7 @@ def test_without_snap_must_raise_exception(self): def test_simple(self): # upload metadata - with mock.patch("snapcraft.storeapi._status_tracker.StatusTracker"): + with mock.patch("snapcraft_legacy.storeapi._status_tracker.StatusTracker"): result = self.run_command(["upload-metadata", self.snap_file]) self.assertThat(result.exit_code, Equals(0)) @@ -102,13 +102,13 @@ def test_simple(self): def test_with_license_and_title(self): self.snap_file = os.path.join( - os.path.dirname(tests.__file__), + os.path.dirname(tests.legacy.__file__), "data", "test-snap-with-icon-license-title.snap", ) # upload metadata - with mock.patch("snapcraft.storeapi._status_tracker.StatusTracker"): + with mock.patch("snapcraft_legacy.storeapi._status_tracker.StatusTracker"): result = self.run_command(["upload-metadata", self.snap_file]) self.assertThat(result.exit_code, Equals(0)) @@ -126,7 +126,7 @@ def test_simple_debug(self): fixtures.EnvironmentVariable("SNAPCRAFT_ENABLE_DEVELOPER_DEBUG", "yes") ) # upload metadata - with mock.patch("snapcraft.storeapi._status_tracker.StatusTracker"): + with mock.patch("snapcraft_legacy.storeapi._status_tracker.StatusTracker"): result = self.run_command(["upload-metadata", self.snap_file]) self.assertThat(result.exit_code, Equals(0)) @@ -140,6 +140,9 @@ def test_upload_metadata_without_login_must_ask(self): self.fake_store_login = fixtures.MockPatchObject(storeapi.StoreClient, "login") self.useFixture(self.fake_store_login) + self.fake_store_login = fixtures.MockPatchObject(storeapi.StoreClient, "logout") + self.useFixture(self.fake_store_login) + self.fake_store_account_info = fixtures.MockPatchObject( storeapi._dashboard_api.DashboardAPI, "get_account_information", @@ -162,7 +165,7 @@ def test_upload_metadata_without_login_must_ask(self): self.useFixture(self.fake_store_account_info) self.fake_metadata.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), + FAKE_UNAUTHORIZED_ERROR, None, ] @@ -219,11 +222,11 @@ def test_forced(self): def test_snap_without_icon(self): snap_file = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap.snap" + os.path.dirname(tests.legacy.__file__), "data", "test-snap.snap" ) # upload metadata - with mock.patch("snapcraft.storeapi._status_tracker.StatusTracker"): + with mock.patch("snapcraft_legacy.storeapi._status_tracker.StatusTracker"): result = self.run_command(["upload-metadata", snap_file]) self.assertThat(result.exit_code, Equals(0)) @@ -236,7 +239,7 @@ def test_push_raises_deprecation_warning(self): self.useFixture(fake_logger) # upload metadata - with mock.patch("snapcraft.storeapi._status_tracker.StatusTracker"): + with mock.patch("snapcraft_legacy.storeapi._status_tracker.StatusTracker"): result = self.run_command(["push-metadata", self.snap_file]) self.assertThat(result.exit_code, Equals(0)) self.assertThat( diff --git a/tests/unit/commands/test_validate.py b/tests/legacy/unit/commands/test_validate.py similarity index 91% rename from tests/unit/commands/test_validate.py rename to tests/legacy/unit/commands/test_validate.py index 417a047d9a..a968cad1ed 100644 --- a/tests/unit/commands/test_validate.py +++ b/tests/legacy/unit/commands/test_validate.py @@ -18,7 +18,7 @@ import fixtures from testtools.matchers import Contains, Equals, FileExists -import snapcraft.storeapi.errors +import snapcraft_legacy.storeapi.errors from . import StoreCommandsBaseTestCase @@ -27,7 +27,7 @@ class ValidateCommandTestCase(StoreCommandsBaseTestCase): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft._store.Popen") + patcher = mock.patch("snapcraft_legacy._store.Popen") self.popen_mock = patcher.start() rv_mock = mock.Mock() rv_mock.returncode = 0 @@ -35,8 +35,6 @@ def setUp(self): self.popen_mock.return_value = rv_mock self.addCleanup(patcher.stop) - self.client.login(email="dummy", password="test correct password") - def test_validate_success(self): result = self.run_command(["validate", "core", "core=3", "test-snap=4"]) @@ -81,7 +79,7 @@ def test_validate_from_branded_store(self): def test_validate_unknown_snap(self): raised = self.assertRaises( - snapcraft.storeapi.errors.SnapNotFoundError, + snapcraft_legacy.storeapi.errors.SnapNotFoundError, self.run_command, ["validate", "notfound", "core=3", "test-snap=4"], ) @@ -90,7 +88,7 @@ def test_validate_unknown_snap(self): def test_validate_bad_argument(self): raised = self.assertRaises( - snapcraft.storeapi.errors.InvalidValidationRequestsError, + snapcraft_legacy.storeapi.errors.InvalidValidationRequestsError, self.run_command, ["validate", "core", "core=foo"], ) @@ -99,7 +97,7 @@ def test_validate_bad_argument(self): def test_validate_with_snap_name(self): self.fake_sign = fixtures.MockPatch( - "snapcraft._store._sign_assertion", return_value=b"" + "snapcraft_legacy._store._sign_assertion", return_value=b"" ) self.useFixture(self.fake_sign) @@ -124,7 +122,7 @@ def test_validate_with_snap_name(self): def test_revoke(self): self.fake_sign = fixtures.MockPatch( - "snapcraft._store._sign_assertion", return_value=b"" + "snapcraft_legacy._store._sign_assertion", return_value=b"" ) self.useFixture(self.fake_sign) @@ -149,7 +147,7 @@ def test_revoke(self): def test_no_revoke(self): self.fake_sign = fixtures.MockPatch( - "snapcraft._store._sign_assertion", return_value=b"" + "snapcraft_legacy._store._sign_assertion", return_value=b"" ) self.useFixture(self.fake_sign) @@ -174,7 +172,7 @@ def test_no_revoke(self): def test_validate_fallback_to_snap_id(self): self.fake_sign = fixtures.MockPatch( - "snapcraft._store._sign_assertion", return_value=b"" + "snapcraft_legacy._store._sign_assertion", return_value=b"" ) self.useFixture(self.fake_sign) @@ -200,7 +198,7 @@ def test_validate_fallback_to_snap_id(self): def test_validate_with_revoke(self): self.fake_sign = fixtures.MockPatch( - "snapcraft._store._sign_assertion", return_value=b"" + "snapcraft_legacy._store._sign_assertion", return_value=b"" ) self.useFixture(self.fake_sign) @@ -225,7 +223,7 @@ def test_validate_with_revoke(self): def test_validate_with_no_revoke(self): self.fake_sign = fixtures.MockPatch( - "snapcraft._store._sign_assertion", return_value=b"" + "snapcraft_legacy._store._sign_assertion", return_value=b"" ) self.useFixture(self.fake_sign) diff --git a/tests/unit/commands/test_logout.py b/tests/legacy/unit/commands/test_version.py similarity index 56% rename from tests/unit/commands/test_logout.py rename to tests/legacy/unit/commands/test_version.py index 1fc57e5839..78d077ed79 100644 --- a/tests/unit/commands/test_logout.py +++ b/tests/legacy/unit/commands/test_version.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2016-2017 Canonical Ltd +# Copyright (C) 2017 Canonical Ltd # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -13,22 +13,21 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import re -from unittest import mock - -from testtools.matchers import Equals, MatchesRegex - -from snapcraft.storeapi import StoreClient +from testtools.matchers import Equals from . import CommandBaseTestCase -class LogoutCommandTestCase(CommandBaseTestCase): - @mock.patch.object(StoreClient, "logout") - def test_logout_clears_config(self, mock_logout): - result = self.run_command(["logout"]) +class VersionCommandTestCase(CommandBaseTestCase): + def test_has_version(self): + result = self.run_command(["--version"]) + self.assertThat(result.exit_code, Equals(0)) + def test_has_version_without_hyphens(self): + result = self.run_command(["version"]) self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, MatchesRegex(".*Credentials cleared.\n", flags=re.DOTALL) - ) + + def test_method_return_same_value(self): + result1 = self.run_command(["version"]) + result2 = self.run_command(["--version"]) + self.assertEqual(result1.output, result2.output) diff --git a/tests/legacy/unit/conftest.py b/tests/legacy/unit/conftest.py new file mode 100644 index 0000000000..9e73452b65 --- /dev/null +++ b/tests/legacy/unit/conftest.py @@ -0,0 +1,108 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import pathlib +from typing import List +from unittest import mock + +import pytest +import xdg + + +def pytest_generate_tests(metafunc): + idlist = [] + argvalues = [] + if metafunc.cls is None: + return + + for scenario in metafunc.cls.scenarios: + idlist.append(scenario[0]) + items = scenario[1].items() + argnames = [x[0] for x in items] + argvalues.append([x[1] for x in items]) + metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class") + + +@pytest.fixture +def mock_subprocess_run(): + """A no-op subprocess.run mock.""" + patcher = mock.patch("subprocess.run") + yield patcher.start() + patcher.stop() + + +@pytest.fixture +def tmp_work_path(tmp_path): + """Setup a temporary directory and chdir to it.""" + os.chdir(tmp_path) + return tmp_path + + +@pytest.fixture +def xdg_dirs(tmp_path, monkeypatch): + """Setup XDG directories in a temporary directory.""" + monkeypatch.setattr( + xdg.BaseDirectory, "xdg_config_home", (tmp_path / ".config").as_posix() + ) + monkeypatch.setattr( + xdg.BaseDirectory, "xdg_data_home", (tmp_path / ".local").as_posix() + ) + monkeypatch.setattr( + xdg.BaseDirectory, "xdg_cache_home", (tmp_path / ".cache").as_posix() + ) + monkeypatch.setattr( + xdg.BaseDirectory, + "xdg_config_dirs", + lambda: [(tmp_path / ".config").as_posix()], + ) + monkeypatch.setattr( + xdg.BaseDirectory, "xdg_data_dirs", lambda: [(tmp_path / ".config").as_posix()] + ) + + monkeypatch.setenv("XDG_CONFIG_HOME", (tmp_path / ".config").as_posix()) + monkeypatch.setenv("XDG_DATA_HOME", (tmp_path / ".local").as_posix()) + monkeypatch.setenv("XDG_CACHE_HOME", (tmp_path / ".cache").as_posix()) + + return tmp_path + + +@pytest.fixture() +def in_snap(monkeypatch): + """Simualte being run from within the context of the Snapcraft snap.""" + monkeypatch.setenv("SNAP", "/snap/snapcraft/current") + monkeypatch.setenv("SNAP_NAME", "snapcraft") + monkeypatch.setenv("SNAP_VERSION", "4.0") + + +@pytest.fixture() +def fake_exists(monkeypatch): + """Fakely return True when checking for preconfigured paths.""" + + class FileCheck: + def __init__(self) -> None: + self._original_exists = os.path.exists + self.paths: List[str] = list() + + def exists(self, path: str) -> bool: + if pathlib.Path(path) in self.paths: + return True + return self._original_exists(path) + + file_checker = FileCheck() + monkeypatch.setattr(os.path, "exists", file_checker.exists) + + return file_checker diff --git a/tests/unit/db/test_datastore.py b/tests/legacy/unit/db/test_datastore.py similarity index 97% rename from tests/unit/db/test_datastore.py rename to tests/legacy/unit/db/test_datastore.py index ad66ecfaae..8fb258d79a 100644 --- a/tests/unit/db/test_datastore.py +++ b/tests/legacy/unit/db/test_datastore.py @@ -21,7 +21,7 @@ import pytest import tinydb -from snapcraft.internal.db import datastore, errors, migration +from snapcraft_legacy.internal.db import datastore, errors, migration @pytest.fixture(autouse=True) diff --git a/tests/unit/db/test_errors.py b/tests/legacy/unit/db/test_errors.py similarity index 96% rename from tests/unit/db/test_errors.py rename to tests/legacy/unit/db/test_errors.py index 7f3752669f..cd4b439b9f 100644 --- a/tests/unit/db/test_errors.py +++ b/tests/legacy/unit/db/test_errors.py @@ -16,7 +16,7 @@ import pathlib -from snapcraft.internal.db import errors +from snapcraft_legacy.internal.db import errors def test_SnapcraftDatastoreVersionUnsupported(): diff --git a/tests/unit/db/test_migration.py b/tests/legacy/unit/db/test_migration.py similarity index 97% rename from tests/unit/db/test_migration.py rename to tests/legacy/unit/db/test_migration.py index 5005cd0473..3dbc7d1d5d 100644 --- a/tests/unit/db/test_migration.py +++ b/tests/legacy/unit/db/test_migration.py @@ -20,7 +20,7 @@ import pytest import tinydb -from snapcraft.internal.db import migration +from snapcraft_legacy.internal.db import migration @pytest.fixture diff --git a/tests/unit/extractors/__init__.py b/tests/legacy/unit/deltas/__init__.py similarity index 100% rename from tests/unit/extractors/__init__.py rename to tests/legacy/unit/deltas/__init__.py diff --git a/tests/unit/deltas/test_deltas.py b/tests/legacy/unit/deltas/test_deltas.py similarity index 97% rename from tests/unit/deltas/test_deltas.py rename to tests/legacy/unit/deltas/test_deltas.py index 29dc77afc5..9c72578b69 100644 --- a/tests/unit/deltas/test_deltas.py +++ b/tests/legacy/unit/deltas/test_deltas.py @@ -21,8 +21,8 @@ from testtools import TestCase from testtools import matchers as m -from snapcraft.internal import deltas -from tests import fixture_setup +from snapcraft_legacy.internal import deltas +from tests.legacy import fixture_setup class BaseDeltaGenerationTestCase(TestCase): @@ -44,7 +44,7 @@ def setUp(self): self.useFixture( fixtures.MockPatch( - "snapcraft.file_utils.get_snap_tool_path", + "snapcraft_legacy.file_utils.get_snap_tool_path", side_effect=lambda x: os.path.join("/usr", "bin", x), ) ) diff --git a/tests/unit/deltas/test_deltas_xdelta3.py b/tests/legacy/unit/deltas/test_deltas_xdelta3.py similarity index 98% rename from tests/unit/deltas/test_deltas_xdelta3.py rename to tests/legacy/unit/deltas/test_deltas_xdelta3.py index b56ae80570..15834f62a1 100644 --- a/tests/unit/deltas/test_deltas_xdelta3.py +++ b/tests/legacy/unit/deltas/test_deltas_xdelta3.py @@ -23,8 +23,8 @@ from progressbar import AnimatedMarker, ProgressBar from testtools import matchers as m -from snapcraft.internal import deltas -from tests import fixture_setup, unit +from snapcraft_legacy.internal import deltas +from tests.legacy import fixture_setup, unit class XDelta3TestCase(unit.TestCase): diff --git a/tests/unit/pluginhandler/__init__.py b/tests/legacy/unit/extractors/__init__.py similarity index 100% rename from tests/unit/pluginhandler/__init__.py rename to tests/legacy/unit/extractors/__init__.py diff --git a/tests/unit/extractors/test_appstream.py b/tests/legacy/unit/extractors/test_appstream.py similarity index 99% rename from tests/unit/extractors/test_appstream.py rename to tests/legacy/unit/extractors/test_appstream.py index 5254b1ac6c..e45f178b07 100644 --- a/tests/unit/extractors/test_appstream.py +++ b/tests/legacy/unit/extractors/test_appstream.py @@ -20,8 +20,8 @@ import testscenarios from testtools.matchers import Equals -from snapcraft.extractors import ExtractedMetadata, _errors, appstream -from tests import unit +from snapcraft_legacy.extractors import ExtractedMetadata, _errors, appstream +from tests.legacy import unit def _create_desktop_file(desktop_file_path, icon: str = None) -> None: @@ -269,7 +269,7 @@ def test_appstream_no_icon_theme_fallback_svgz(self): class AppstreamTest(unit.TestCase): def test_appstream_with_ul(self): - file_name = "snapcraft.appdata.xml" + file_name = "snapcraft_legacy.appdata.xml" content = textwrap.dedent( """\ @@ -322,7 +322,7 @@ def test_appstream_with_ul(self): ) def test_appstream_with_ol(self): - file_name = "snapcraft.appdata.xml" + file_name = "snapcraft_legacy.appdata.xml" content = textwrap.dedent( """\ @@ -375,7 +375,7 @@ def test_appstream_with_ol(self): ) def test_appstream_with_ul_in_p(self): - file_name = "snapcraft.appdata.xml" + file_name = "snapcraft_legacy.appdata.xml" content = textwrap.dedent( """\ diff --git a/tests/unit/extractors/test_metadata.py b/tests/legacy/unit/extractors/test_metadata.py similarity index 97% rename from tests/unit/extractors/test_metadata.py rename to tests/legacy/unit/extractors/test_metadata.py index ee77160c9c..d2bbd65170 100644 --- a/tests/unit/extractors/test_metadata.py +++ b/tests/legacy/unit/extractors/test_metadata.py @@ -16,8 +16,8 @@ from testtools.matchers import Equals, Not -from snapcraft.extractors._metadata import ExtractedMetadata -from tests import unit +from snapcraft_legacy.extractors._metadata import ExtractedMetadata +from tests.legacy import unit class ExtractedMetadataTestCase(unit.TestCase): diff --git a/tests/unit/extractors/test_setuppy.py b/tests/legacy/unit/extractors/test_setuppy.py similarity index 97% rename from tests/unit/extractors/test_setuppy.py rename to tests/legacy/unit/extractors/test_setuppy.py index e4c2c0e336..ab362d5fd0 100644 --- a/tests/unit/extractors/test_setuppy.py +++ b/tests/legacy/unit/extractors/test_setuppy.py @@ -19,8 +19,8 @@ from testscenarios import multiply_scenarios from testtools.matchers import Equals -from snapcraft.extractors import ExtractedMetadata, _errors, setuppy -from tests import unit +from snapcraft_legacy.extractors import ExtractedMetadata, _errors, setuppy +from tests.legacy import unit class TestSetupPy: diff --git a/tests/unit/lifecycle/__init__.py b/tests/legacy/unit/lifecycle/__init__.py similarity index 78% rename from tests/unit/lifecycle/__init__.py rename to tests/legacy/unit/lifecycle/__init__.py index 65b044de7e..2f4dfc53bc 100644 --- a/tests/unit/lifecycle/__init__.py +++ b/tests/legacy/unit/lifecycle/__init__.py @@ -19,9 +19,9 @@ import fixtures -import snapcraft -from snapcraft.internal import project_loader -from tests import unit +import snapcraft_legacy +from snapcraft_legacy.internal import project_loader +from tests.legacy import unit class LifecycleTestBase(unit.TestCase): @@ -30,30 +30,30 @@ def setUp(self): self.fake_logger = fixtures.FakeLogger(level=logging.INFO) self.useFixture(self.fake_logger) - self.project_options = snapcraft.ProjectOptions() + self.project_options = snapcraft_legacy.ProjectOptions() self.fake_install_build_packages = fixtures.MockPatch( - "snapcraft.internal.lifecycle._runner._install_build_packages", + "snapcraft_legacy.internal.lifecycle._runner._install_build_packages", return_value=list(), ) self.useFixture(self.fake_install_build_packages) self.useFixture( fixtures.MockPatch( - "snapcraft.internal.project_loader._config.Config.get_build_packages", + "snapcraft_legacy.internal.project_loader._config.Config.get_build_packages", return_value=set(), ) ) self.fake_install_build_snaps = fixtures.MockPatch( - "snapcraft.internal.lifecycle._runner._install_build_snaps", + "snapcraft_legacy.internal.lifecycle._runner._install_build_snaps", return_value=list(), ) self.useFixture(self.fake_install_build_snaps) self.useFixture( fixtures.MockPatch( - "snapcraft.internal.project_loader._config.Config.get_build_snaps", + "snapcraft_legacy.internal.project_loader._config.Config.get_build_snaps", return_value=set(), ) ) @@ -77,7 +77,7 @@ def make_snapcraft_project(self, parts, snap_type=""): self.snapcraft_yaml_file_path = self.make_snapcraft_yaml( yaml.format(parts=parts, type=snap_type) ) - project = snapcraft.project.Project( + project = snapcraft_legacy.project.Project( snapcraft_yaml_file_path=self.snapcraft_yaml_file_path ) return project_loader.load_config(project) diff --git a/tests/unit/lifecycle/test_errors.py b/tests/legacy/unit/lifecycle/test_errors.py similarity index 95% rename from tests/unit/lifecycle/test_errors.py rename to tests/legacy/unit/lifecycle/test_errors.py index d2c8c47aec..318ffa47d4 100644 --- a/tests/unit/lifecycle/test_errors.py +++ b/tests/legacy/unit/lifecycle/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.lifecycle import errors +from snapcraft_legacy.internal.lifecycle import errors class TestErrorFormatting: diff --git a/tests/unit/lifecycle/test_global_state.py b/tests/legacy/unit/lifecycle/test_global_state.py similarity index 89% rename from tests/unit/lifecycle/test_global_state.py rename to tests/legacy/unit/lifecycle/test_global_state.py index 60165bb05d..1f91b2c3fb 100644 --- a/tests/unit/lifecycle/test_global_state.py +++ b/tests/legacy/unit/lifecycle/test_global_state.py @@ -18,11 +18,11 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.internal import lifecycle, project_loader, states, steps -from snapcraft.project import Project -from snapcraft.storeapi.errors import SnapNotFoundError -from snapcraft.storeapi.info import SnapInfo -from tests import fixture_setup +from snapcraft_legacy.internal import lifecycle, project_loader, states, steps +from snapcraft_legacy.project import Project +from snapcraft_legacy.storeapi.errors import SnapNotFoundError +from snapcraft_legacy.storeapi.info import SnapInfo +from tests.legacy import fixture_setup class TestGlobalState(TestCase): @@ -54,11 +54,13 @@ def setUp(self): ) self.useFixture( - fixtures.MockPatch("snapcraft.internal.lifecycle._runner._Executor.run") + fixtures.MockPatch( + "snapcraft_legacy.internal.lifecycle._runner._Executor.run" + ) ) self.useFixture( - fixtures.MockPatch("snapcraft.internal.repo.snaps.install_snaps") + fixtures.MockPatch("snapcraft_legacy.internal.repo.snaps.install_snaps") ) # Avoid unnecessary calls to info. @@ -100,7 +102,7 @@ def setUp(self): "snap-id": "CSO04Jhav2yK0uz97cr0ipQRyqg0qQL6", } self.fake_storeapi_get_info = fixtures.MockPatch( - "snapcraft.storeapi._snap_api.SnapAPI.get_info", + "snapcraft_legacy.storeapi._snap_api.SnapAPI.get_info", return_value=SnapInfo(info), ) self.useFixture(self.fake_storeapi_get_info) diff --git a/tests/unit/lifecycle/test_lifecycle.py b/tests/legacy/unit/lifecycle/test_lifecycle.py similarity index 89% rename from tests/unit/lifecycle/test_lifecycle.py rename to tests/legacy/unit/lifecycle/test_lifecycle.py index 82a5f641e3..1ae3978a8f 100644 --- a/tests/unit/lifecycle/test_lifecycle.py +++ b/tests/legacy/unit/lifecycle/test_lifecycle.py @@ -29,11 +29,17 @@ Not, ) -import snapcraft -from snapcraft.internal import errors, lifecycle, pluginhandler, project_loader, steps -from snapcraft.internal.lifecycle._runner import _replace_in_part -from snapcraft.project import Project -from tests import fixture_setup, unit +import snapcraft_legacy +from snapcraft_legacy.internal import ( + errors, + lifecycle, + pluginhandler, + project_loader, + steps, +) +from snapcraft_legacy.internal.lifecycle._runner import _replace_in_part +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit from . import LifecycleTestBase @@ -62,7 +68,7 @@ def __init__(self): self.assertThat(new_part.plugin.options.source, Equals(part.part_install_dir)) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_dependency_is_staged_when_required(self, mock_install_build_snaps): project_config = self.make_snapcraft_project( textwrap.dedent( @@ -85,7 +91,7 @@ def test_dependency_is_staged_when_required(self, mock_install_build_snaps): Contains("'part2' has dependencies that need to be staged: part1"), ) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_no_exception_when_dependency_is_required_but_already_staged( self, mock_install_build_snaps ): @@ -116,9 +122,9 @@ def _fake_should_step_run(self, step, force=False): def test_dirty_stage_part_with_built_dependent_raises(self): # Set the option to error on dirty/outdated steps - with snapcraft.config.CLIConfig() as cli_config: + with snapcraft_legacy.config.CLIConfig() as cli_config: cli_config.set_outdated_step_action( - snapcraft.config.OutdatedStepAction.ERROR + snapcraft_legacy.config.OutdatedStepAction.ERROR ) project_config = self.make_snapcraft_project( @@ -169,12 +175,12 @@ def _fake_dirty_report(self, step): self.assertThat(raised.part, Equals("part2")) self.assertThat(raised.report, Equals("A dependency has changed: 'part1'\n")) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_dirty_build_raises(self, mock_install_build_snaps): # Set the option to error on dirty/outdated steps - with snapcraft.config.CLIConfig() as cli_config: + with snapcraft_legacy.config.CLIConfig() as cli_config: cli_config.set_outdated_step_action( - snapcraft.config.OutdatedStepAction.ERROR + snapcraft_legacy.config.OutdatedStepAction.ERROR ) project_config = self.make_snapcraft_project( @@ -219,12 +225,12 @@ def _fake_dirty_report(self, step): ) self.assertThat(raised.parts_names, Equals("part1")) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_dirty_pull_raises(self, mock_install_build_snaps): # Set the option to error on dirty/outdated steps - with snapcraft.config.CLIConfig() as cli_config: + with snapcraft_legacy.config.CLIConfig() as cli_config: cli_config.set_outdated_step_action( - snapcraft.config.OutdatedStepAction.ERROR + snapcraft_legacy.config.OutdatedStepAction.ERROR ) project_config = self.make_snapcraft_project( @@ -266,9 +272,9 @@ def _fake_dirty_report(self, step): Equals("The 'bar' and 'foo' project options appear to have changed.\n"), ) - @mock.patch.object(snapcraft.BasePlugin, "enable_cross_compilation") - @mock.patch("snapcraft.repo.Repo.install_build_packages") - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch.object(snapcraft_legacy.BasePlugin, "enable_cross_compilation") + @mock.patch("snapcraft_legacy.repo.Repo.install_build_packages") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_pull_is_dirty_if_target_arch_changes( self, mock_install_build_snaps, @@ -276,9 +282,9 @@ def test_pull_is_dirty_if_target_arch_changes( mock_enable_cross_compilation, ): # Set the option to error on dirty/outdated steps - with snapcraft.config.CLIConfig() as cli_config: + with snapcraft_legacy.config.CLIConfig() as cli_config: cli_config.set_outdated_step_action( - snapcraft.config.OutdatedStepAction.ERROR + snapcraft_legacy.config.OutdatedStepAction.ERROR ) mock_install_build_packages.return_value = [] @@ -375,7 +381,7 @@ def test_clean_removes_global_state(self): lifecycle.clean(project_config.project, parts=None) self.assertThat(os.path.join("snap", ".snapcraft"), Not(DirExists())) - @mock.patch("snapcraft.internal.mountinfo.MountInfo.for_root") + @mock.patch("snapcraft_legacy.internal.mountinfo.MountInfo.for_root") def test_clean_leaves_prime_alone_for_tried(self, mock_for_root): project_config = self.make_snapcraft_project( textwrap.dedent( @@ -453,7 +459,9 @@ def test_prime_with_build_info_records_snapcraft_yaml(self): class OfflineTestCase(unit.TestCase): def test_install_build_packages(self): - with mock.patch("snapcraft.repo.Repo.install_build_packages") as mock_install: + with mock.patch( + "snapcraft_legacy.repo.Repo.install_build_packages" + ) as mock_install: lifecycle._runner._install_build_packages({"pkg1", "pkg2"}) assert mock_install.mock_calls == [mock.call({"pkg1", "pkg2"})] @@ -461,14 +469,16 @@ def test_install_build_packages(self): def test_install_build_packages_offline(self): self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_OFFLINE", "True")) - with mock.patch("snapcraft.repo.Repo.install_build_packages") as mock_install: + with mock.patch( + "snapcraft_legacy.repo.Repo.install_build_packages" + ) as mock_install: pkgs = lifecycle._runner._install_build_packages({"pkg1", "pkg2"}) assert mock_install.mock_calls == [] assert pkgs == [] def test_install_build_snaps(self): - with mock.patch("snapcraft.repo.snaps.install_snaps") as mock_install: + with mock.patch("snapcraft_legacy.repo.snaps.install_snaps") as mock_install: lifecycle._runner._install_build_snaps( {"build_snap1", "build_snap2"}, {"content_snap"} ) @@ -482,7 +492,7 @@ def test_install_build_snaps(self): def test_install_build_snaps_offline(self): self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_OFFLINE", "True")) - with mock.patch("snapcraft.repo.snaps.install_snaps") as mock_install: + with mock.patch("snapcraft_legacy.repo.snaps.install_snaps") as mock_install: snaps = lifecycle._runner._install_build_snaps( {"build_snap1", "build_snap2"}, {"content_snap"} ) diff --git a/tests/unit/lifecycle/test_order.py b/tests/legacy/unit/lifecycle/test_order.py similarity index 99% rename from tests/unit/lifecycle/test_order.py rename to tests/legacy/unit/lifecycle/test_order.py index c54342d42e..71e720d0be 100644 --- a/tests/unit/lifecycle/test_order.py +++ b/tests/legacy/unit/lifecycle/test_order.py @@ -21,9 +21,9 @@ from testtools.matchers import Contains, Equals, HasLength -import snapcraft -from snapcraft.internal import lifecycle, pluginhandler, states, steps -from snapcraft.internal.lifecycle._status_cache import StatusCache +import snapcraft_legacy +from snapcraft_legacy.internal import lifecycle, pluginhandler, states, steps +from snapcraft_legacy.internal.lifecycle._status_cache import StatusCache from . import LifecycleTestBase @@ -155,9 +155,9 @@ def setUp(self): ) # Set the option to automatically clean dirty/outdated steps - with snapcraft.config.CLIConfig() as cli_config: + with snapcraft_legacy.config.CLIConfig() as cli_config: cli_config.set_outdated_step_action( - snapcraft.config.OutdatedStepAction.CLEAN + snapcraft_legacy.config.OutdatedStepAction.CLEAN ) def set_attributes(self, kwargs): diff --git a/tests/unit/lifecycle/test_order_core20.py b/tests/legacy/unit/lifecycle/test_order_core20.py similarity index 89% rename from tests/unit/lifecycle/test_order_core20.py rename to tests/legacy/unit/lifecycle/test_order_core20.py index 2c75319bf1..dcda9ee3a2 100644 --- a/tests/unit/lifecycle/test_order_core20.py +++ b/tests/legacy/unit/lifecycle/test_order_core20.py @@ -19,11 +19,11 @@ import pytest -from snapcraft.internal import steps -from snapcraft.internal.lifecycle._runner import _Executor as Executor -from snapcraft.internal.meta.snap import Snap -from snapcraft.internal.pluginhandler._build_attributes import BuildAttributes -from snapcraft.project import Project +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.lifecycle._runner import _Executor as Executor +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.internal.pluginhandler._build_attributes import BuildAttributes +from snapcraft_legacy.project import Project class FakePart: diff --git a/tests/unit/lifecycle/test_snap_installation.py b/tests/legacy/unit/lifecycle/test_snap_installation.py similarity index 82% rename from tests/unit/lifecycle/test_snap_installation.py rename to tests/legacy/unit/lifecycle/test_snap_installation.py index 570accfc21..ead9431725 100644 --- a/tests/unit/lifecycle/test_snap_installation.py +++ b/tests/legacy/unit/lifecycle/test_snap_installation.py @@ -19,7 +19,7 @@ from testtools import TestCase from testtools.matchers import Contains -from snapcraft.internal.lifecycle._runner import _install_build_snaps +from snapcraft_legacy.internal.lifecycle._runner import _install_build_snaps class TestSnapInstall(TestCase): @@ -29,7 +29,7 @@ def setUp(self): self.fake_logger = fixtures.FakeLogger(level=logging.WARNING) self.useFixture(self.fake_logger) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_install(self, mock_install_build_snaps): _install_build_snaps({"foo/latest/stable", "bar/default/edge"}, set()) @@ -37,7 +37,7 @@ def test_install(self, mock_install_build_snaps): {"foo/latest/stable", "bar/default/edge"} ) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_install_with_content_snap(self, mock_install_build_snaps): _install_build_snaps({"foo/latest/stable"}, {"content1/latest/stable"}) @@ -45,8 +45,10 @@ def test_install_with_content_snap(self, mock_install_build_snaps): [mock.call({"foo/latest/stable"}), mock.call(["content1/latest/stable"])] ) - @mock.patch("snapcraft.internal.common.is_process_container", return_value=True) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch( + "snapcraft_legacy.internal.common.is_process_container", return_value=True + ) + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_install_on_docker(self, mock_install_build_snaps, mock_docker_instance): _install_build_snaps({"foo/latest/stable", "bar/default/edge"}, set()) @@ -59,8 +61,10 @@ def test_install_on_docker(self, mock_install_build_snaps, mock_docker_instance) ), ) - @mock.patch("snapcraft.internal.common.is_process_container", return_value=True) - @mock.patch("snapcraft.repo.snaps.install_snaps") + @mock.patch( + "snapcraft_legacy.internal.common.is_process_container", return_value=True + ) + @mock.patch("snapcraft_legacy.repo.snaps.install_snaps") def test_install_with_content_snap_on_docker( self, mock_install_build_snaps, mock_docker_instance ): diff --git a/tests/unit/lifecycle/test_status_cache.py b/tests/legacy/unit/lifecycle/test_status_cache.py similarity index 97% rename from tests/unit/lifecycle/test_status_cache.py rename to tests/legacy/unit/lifecycle/test_status_cache.py index bc96ee3f2d..09d76b1028 100644 --- a/tests/unit/lifecycle/test_status_cache.py +++ b/tests/legacy/unit/lifecycle/test_status_cache.py @@ -17,8 +17,8 @@ import os import textwrap -from snapcraft.internal import lifecycle, states, steps -from snapcraft.internal.lifecycle._status_cache import StatusCache +from snapcraft_legacy.internal import lifecycle, states, steps +from snapcraft_legacy.internal.lifecycle._status_cache import StatusCache from . import LifecycleTestBase diff --git a/tests/unit/plugins/__init__.py b/tests/legacy/unit/meta/__init__.py similarity index 100% rename from tests/unit/plugins/__init__.py rename to tests/legacy/unit/meta/__init__.py diff --git a/tests/unit/meta/test_application.py b/tests/legacy/unit/meta/test_application.py similarity index 98% rename from tests/unit/meta/test_application.py rename to tests/legacy/unit/meta/test_application.py index 810a1c61a1..58f30a571b 100644 --- a/tests/unit/meta/test_application.py +++ b/tests/legacy/unit/meta/test_application.py @@ -19,9 +19,9 @@ from testtools.matchers import Contains, Equals, FileExists, Not -from snapcraft import yaml_utils -from snapcraft.internal.meta import application, desktop, errors -from tests import unit +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal.meta import application, desktop, errors +from tests.legacy import unit class AppCommandTest(unit.TestCase): diff --git a/tests/unit/meta/test_command.py b/tests/legacy/unit/meta/test_command.py similarity index 99% rename from tests/unit/meta/test_command.py rename to tests/legacy/unit/meta/test_command.py index 17503ae11a..19c869dbee 100644 --- a/tests/unit/meta/test_command.py +++ b/tests/legacy/unit/meta/test_command.py @@ -21,8 +21,8 @@ import fixtures from testtools.matchers import Equals, FileContains, FileExists, Is -from snapcraft.internal.meta import command, errors -from tests import unit +from snapcraft_legacy.internal.meta import command, errors +from tests.legacy import unit def _create_file(file_path: str, *, mode=0o755, contents="") -> None: diff --git a/tests/unit/meta/test_command_mangle.py b/tests/legacy/unit/meta/test_command_mangle.py similarity index 99% rename from tests/unit/meta/test_command_mangle.py rename to tests/legacy/unit/meta/test_command_mangle.py index 6dda4c58c9..c9d124cc7c 100644 --- a/tests/unit/meta/test_command_mangle.py +++ b/tests/legacy/unit/meta/test_command_mangle.py @@ -16,7 +16,7 @@ import logging -from snapcraft.internal.meta import command +from snapcraft_legacy.internal.meta import command class TestCommandMangle: diff --git a/tests/unit/meta/test_desktop.py b/tests/legacy/unit/meta/test_desktop.py similarity index 98% rename from tests/unit/meta/test_desktop.py rename to tests/legacy/unit/meta/test_desktop.py index 8cc90a1b94..95b70a50cb 100644 --- a/tests/unit/meta/test_desktop.py +++ b/tests/legacy/unit/meta/test_desktop.py @@ -18,8 +18,8 @@ import pytest -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.desktop import DesktopFile +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.desktop import DesktopFile class TestDesktopExec: diff --git a/tests/unit/meta/test_errors.py b/tests/legacy/unit/meta/test_errors.py similarity index 99% rename from tests/unit/meta/test_errors.py rename to tests/legacy/unit/meta/test_errors.py index d7c20282cb..ecbcffa7ad 100644 --- a/tests/unit/meta/test_errors.py +++ b/tests/legacy/unit/meta/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.meta import errors +from snapcraft_legacy.internal.meta import errors class TestErrorFormatting: diff --git a/tests/unit/meta/test_hook.py b/tests/legacy/unit/meta/test_hook.py similarity index 96% rename from tests/unit/meta/test_hook.py rename to tests/legacy/unit/meta/test_hook.py index c836e94e13..279dec6763 100644 --- a/tests/unit/meta/test_hook.py +++ b/tests/legacy/unit/meta/test_hook.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.hooks import Hook -from tests import unit +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.hooks import Hook +from tests.legacy import unit class GenericHookTests(unit.TestCase): diff --git a/tests/unit/meta/test_meta.py b/tests/legacy/unit/meta/test_meta.py similarity index 99% rename from tests/unit/meta/test_meta.py rename to tests/legacy/unit/meta/test_meta.py index 2178aff75c..04c027139f 100644 --- a/tests/unit/meta/test_meta.py +++ b/tests/legacy/unit/meta/test_meta.py @@ -34,12 +34,12 @@ Not, ) -from snapcraft import extractors, yaml_utils -from snapcraft.internal import errors, project_loader, states -from snapcraft.internal.meta import _snap_packaging -from snapcraft.internal.meta import errors as meta_errors -from snapcraft.project import Project -from tests import fixture_setup, unit +from snapcraft_legacy import extractors, yaml_utils +from snapcraft_legacy.internal import errors, project_loader, states +from snapcraft_legacy.internal.meta import _snap_packaging +from snapcraft_legacy.internal.meta import errors as meta_errors +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit class CreateBaseTestCase(unit.TestCase): @@ -1231,7 +1231,7 @@ def test_generate_hook_wrappers(self): ), ) - @patch("snapcraft.internal.project_loader._config.Config.snap_env") + @patch("snapcraft_legacy.internal.project_loader._config.Config.snap_env") def test_generated_hook_wrappers_include_environment(self, mock_snap_env): mock_snap_env.return_value = ["PATH={}/foo".format(self.prime_dir)] @@ -1517,7 +1517,7 @@ def test_stable_required(self): global_state_path = "global_state" self.useFixture( fixtures.MockPatch( - "snapcraft.project.Project._get_global_state_file_path", + "snapcraft_legacy.project.Project._get_global_state_file_path", return_value=global_state_path, ) ) @@ -1534,7 +1534,7 @@ def test_stable_but_devel_required(self): global_state_path = "global_state" self.useFixture( fixtures.MockPatch( - "snapcraft.project.Project._get_global_state_file_path", + "snapcraft_legacy.project.Project._get_global_state_file_path", return_value=global_state_path, ) ) diff --git a/tests/unit/meta/test_package_repository.py b/tests/legacy/unit/meta/test_package_repository.py similarity index 99% rename from tests/unit/meta/test_package_repository.py rename to tests/legacy/unit/meta/test_package_repository.py index 6ce9862ade..c81c6c5688 100644 --- a/tests/unit/meta/test_package_repository.py +++ b/tests/legacy/unit/meta/test_package_repository.py @@ -17,8 +17,8 @@ import pytest -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.package_repository import ( +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.package_repository import ( PackageRepository, PackageRepositoryApt, PackageRepositoryAptPpa, diff --git a/tests/unit/meta/test_plugs.py b/tests/legacy/unit/meta/test_plugs.py similarity index 96% rename from tests/unit/meta/test_plugs.py rename to tests/legacy/unit/meta/test_plugs.py index bdb483cfc7..5db7dda980 100644 --- a/tests/unit/meta/test_plugs.py +++ b/tests/legacy/unit/meta/test_plugs.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals, Is -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.plugs import ContentPlug, Plug -from tests import unit +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.plugs import ContentPlug, Plug +from tests.legacy import unit class GenericPlugTests(unit.TestCase): diff --git a/tests/unit/meta/test_slots.py b/tests/legacy/unit/meta/test_slots.py similarity index 98% rename from tests/unit/meta/test_slots.py rename to tests/legacy/unit/meta/test_slots.py index eea7145bea..a44496075a 100644 --- a/tests/unit/meta/test_slots.py +++ b/tests/legacy/unit/meta/test_slots.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.slots import ContentSlot, DbusSlot, Slot -from tests import unit +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.slots import ContentSlot, DbusSlot, Slot +from tests.legacy import unit class GenericSlotTests(unit.TestCase): diff --git a/tests/unit/meta/test_snap.py b/tests/legacy/unit/meta/test_snap.py similarity index 98% rename from tests/unit/meta/test_snap.py rename to tests/legacy/unit/meta/test_snap.py index 6af13dac94..e957ec4a8a 100644 --- a/tests/unit/meta/test_snap.py +++ b/tests/legacy/unit/meta/test_snap.py @@ -21,10 +21,10 @@ from testtools.matchers import Equals -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.internal.meta.system_user import SystemUserScope -from tests import unit +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.internal.meta.system_user import SystemUserScope +from tests.legacy import unit class SnapTests(unit.TestCase): @@ -481,7 +481,7 @@ def test_get_provider_content_directories_with_content_plugs(self): snap = Snap.from_dict(snap_dict=snap_dict) snap.validate() - patcher = mock.patch("snapcraft.internal.common.get_installed_snap_path") + patcher = mock.patch("snapcraft_legacy.internal.common.get_installed_snap_path") mock_core_path = patcher.start() mock_core_path.return_value = self.path self.addCleanup(patcher.stop) diff --git a/tests/unit/meta/test_snap_packaging.py b/tests/legacy/unit/meta/test_snap_packaging.py similarity index 96% rename from tests/unit/meta/test_snap_packaging.py rename to tests/legacy/unit/meta/test_snap_packaging.py index 521842f456..c071752cb0 100644 --- a/tests/unit/meta/test_snap_packaging.py +++ b/tests/legacy/unit/meta/test_snap_packaging.py @@ -19,10 +19,10 @@ from testtools.matchers import Equals, FileContains, Is -from snapcraft.internal.meta._snap_packaging import _SnapPackaging -from snapcraft.internal.project_loader import load_config -from snapcraft.project import Project -from tests import fixture_setup, unit +from snapcraft_legacy.internal.meta._snap_packaging import _SnapPackaging +from snapcraft_legacy.internal.project_loader import load_config +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit class SnapPackagingRunnerTests(unit.TestCase): diff --git a/tests/unit/meta/test_system_user.py b/tests/legacy/unit/meta/test_system_user.py similarity index 96% rename from tests/unit/meta/test_system_user.py rename to tests/legacy/unit/meta/test_system_user.py index a19dd2cef4..ddca88c2cf 100644 --- a/tests/unit/meta/test_system_user.py +++ b/tests/legacy/unit/meta/test_system_user.py @@ -16,9 +16,9 @@ from testtools.matchers import Equals -from snapcraft.internal.meta import errors -from snapcraft.internal.meta.system_user import SystemUser, SystemUserScope -from tests import unit +from snapcraft_legacy.internal.meta import errors +from snapcraft_legacy.internal.meta.system_user import SystemUser, SystemUserScope +from tests.legacy import unit class SystemUserTests(unit.TestCase): diff --git a/tests/unit/part_loader.py b/tests/legacy/unit/part_loader.py similarity index 87% rename from tests/unit/part_loader.py rename to tests/legacy/unit/part_loader.py index 459c658cb9..91d2103562 100644 --- a/tests/unit/part_loader.py +++ b/tests/legacy/unit/part_loader.py @@ -16,9 +16,9 @@ from unittest import mock -from snapcraft.internal import elf, pluginhandler -from snapcraft.internal.project_loader import grammar_processing -from snapcraft.project import Project, _schema +from snapcraft_legacy.internal import elf, pluginhandler +from snapcraft_legacy.internal.project_loader import grammar_processing +from snapcraft_legacy.project import Project, _schema def load_part( @@ -67,7 +67,11 @@ def load_part( if not stage_packages_repo: stage_packages_repo = mock.Mock() grammar_processor = grammar_processing.PartGrammarProcessor( - plugin=plugin, properties=properties, project=project, repo=stage_packages_repo + plugin=plugin, + properties=properties, + arch=project.deb_arch, + target_arch=project.target_arch, + repo=stage_packages_repo, ) return pluginhandler.PluginHandler( diff --git a/tests/unit/plugins/v1/python/__init__.py b/tests/legacy/unit/pluginhandler/__init__.py similarity index 100% rename from tests/unit/plugins/v1/python/__init__.py rename to tests/legacy/unit/pluginhandler/__init__.py diff --git a/tests/unit/pluginhandler/mocks.py b/tests/legacy/unit/pluginhandler/mocks.py similarity index 93% rename from tests/unit/pluginhandler/mocks.py rename to tests/legacy/unit/pluginhandler/mocks.py index 431bdbb9af..d025c4f7ba 100644 --- a/tests/unit/pluginhandler/mocks.py +++ b/tests/legacy/unit/pluginhandler/mocks.py @@ -14,10 +14,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft +import snapcraft_legacy -class TestPlugin(snapcraft.BasePlugin): +class TestPlugin(snapcraft_legacy.BasePlugin): @classmethod def schema(cls): return { diff --git a/tests/unit/pluginhandler/test_clean.py b/tests/legacy/unit/pluginhandler/test_clean.py similarity index 99% rename from tests/unit/pluginhandler/test_clean.py rename to tests/legacy/unit/pluginhandler/test_clean.py index 1da3c0eb70..f701cf2761 100644 --- a/tests/unit/pluginhandler/test_clean.py +++ b/tests/legacy/unit/pluginhandler/test_clean.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -from snapcraft import file_utils -from snapcraft.internal import errors, pluginhandler, steps -from tests.unit import TestCase, load_part +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import errors, pluginhandler, steps +from tests.legacy.unit import TestCase, load_part class CleanTestCase(TestCase): diff --git a/tests/unit/pluginhandler/test_dirty_report.py b/tests/legacy/unit/pluginhandler/test_dirty_report.py similarity index 97% rename from tests/unit/pluginhandler/test_dirty_report.py rename to tests/legacy/unit/pluginhandler/test_dirty_report.py index 0afaf4371c..4d17923a85 100644 --- a/tests/unit/pluginhandler/test_dirty_report.py +++ b/tests/legacy/unit/pluginhandler/test_dirty_report.py @@ -16,8 +16,11 @@ from testscenarios import multiply_scenarios -from snapcraft.internal import steps -from snapcraft.internal.pluginhandler._dirty_report import Dependency, DirtyReport +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.pluginhandler._dirty_report import ( + Dependency, + DirtyReport, +) class TestDirtyReportGetReport: diff --git a/tests/unit/pluginhandler/test_metadata_extraction.py b/tests/legacy/unit/pluginhandler/test_metadata_extraction.py similarity index 94% rename from tests/unit/pluginhandler/test_metadata_extraction.py rename to tests/legacy/unit/pluginhandler/test_metadata_extraction.py index 6802c2af80..6b809bddd2 100644 --- a/tests/unit/pluginhandler/test_metadata_extraction.py +++ b/tests/legacy/unit/pluginhandler/test_metadata_extraction.py @@ -19,10 +19,10 @@ import fixtures from testtools.matchers import Contains, Equals -from snapcraft import extractors -from snapcraft.internal import errors -from snapcraft.internal.pluginhandler import extract_metadata -from tests import fixture_setup, unit +from snapcraft_legacy import extractors +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.pluginhandler import extract_metadata +from tests.legacy import fixture_setup, unit class MetadataExtractionTestCase(unit.TestCase): diff --git a/tests/unit/pluginhandler/test_missing_dependency.py b/tests/legacy/unit/pluginhandler/test_missing_dependency.py similarity index 94% rename from tests/unit/pluginhandler/test_missing_dependency.py rename to tests/legacy/unit/pluginhandler/test_missing_dependency.py index a2d1adddbe..adeadf18ca 100644 --- a/tests/unit/pluginhandler/test_missing_dependency.py +++ b/tests/legacy/unit/pluginhandler/test_missing_dependency.py @@ -20,9 +20,11 @@ import fixtures from testtools.matchers import Equals -from snapcraft.internal import repo -from snapcraft.internal.pluginhandler._dependencies import MissingDependencyResolver -from tests import unit +from snapcraft_legacy.internal import repo +from snapcraft_legacy.internal.pluginhandler._dependencies import ( + MissingDependencyResolver, +) +from tests.legacy import unit class MissingDependencyTest(unit.TestCase): @@ -40,7 +42,7 @@ def fake_repo_query(*args, **kwargs): self.useFixture( fixtures.MockPatch( - "snapcraft.internal.repo.Repo.get_package_for_file", + "snapcraft_legacy.internal.repo.Repo.get_package_for_file", side_effect=fake_repo_query, ) ) diff --git a/tests/unit/pluginhandler/test_patcher.py b/tests/legacy/unit/pluginhandler/test_patcher.py similarity index 92% rename from tests/unit/pluginhandler/test_patcher.py rename to tests/legacy/unit/pluginhandler/test_patcher.py index ba5e7039f4..e22d3b0b64 100644 --- a/tests/unit/pluginhandler/test_patcher.py +++ b/tests/legacy/unit/pluginhandler/test_patcher.py @@ -18,33 +18,35 @@ import pytest -from snapcraft import file_utils -from snapcraft.internal import errors -from snapcraft.internal.pluginhandler import PartPatcher -from tests.unit import load_part +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.pluginhandler import PartPatcher +from tests.legacy.unit import load_part @pytest.fixture def mock_elf_patcher(): - """Return a mock for snapcraft.internal.elf.Patcher.""" - patcher = mock.patch("snapcraft.internal.elf.Patcher", autospec=True) + """Return a mock for snapcraft_legacy.internal.elf.Patcher.""" + patcher = mock.patch("snapcraft_legacy.internal.elf.Patcher", autospec=True) yield patcher.start() patcher.stop() @pytest.fixture def mock_partpatcher(): - """Return a mock for snapcraft.internal.pluginhandler.PartPatcher.""" - patcher = mock.patch("snapcraft.internal.pluginhandler.PartPatcher", autospec=True) + """Return a mock for snapcraft_legacy.internal.pluginhandler.PartPatcher.""" + patcher = mock.patch( + "snapcraft_legacy.internal.pluginhandler.PartPatcher", autospec=True + ) yield patcher.start() patcher.stop() @pytest.fixture(autouse=True) def mock_find_linker(): - """Return a mock for snapcraft.internal.elf.find_linker.""" + """Return a mock for snapcraft_legacy.internal.elf.find_linker.""" patcher = mock.patch( - "snapcraft.internal.elf.find_linker", + "snapcraft_legacy.internal.elf.find_linker", autospec=True, return_value="/snap/test-snap/current/lib/x86_64-linux-gnu/ld-2.27.so", ) diff --git a/tests/unit/pluginhandler/test_plugin_loader.py b/tests/legacy/unit/pluginhandler/test_plugin_loader.py similarity index 88% rename from tests/unit/pluginhandler/test_plugin_loader.py rename to tests/legacy/unit/pluginhandler/test_plugin_loader.py index ba1690cbae..92cc460ac1 100644 --- a/tests/unit/pluginhandler/test_plugin_loader.py +++ b/tests/legacy/unit/pluginhandler/test_plugin_loader.py @@ -22,11 +22,11 @@ import fixtures from testtools.matchers import Equals, IsInstance -from snapcraft.internal import errors -from snapcraft.plugins._plugin_finder import _PLUGINS -from snapcraft.plugins.v1 import PluginV1 -from snapcraft.plugins.v2 import PluginV2 -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins._plugin_finder import _PLUGINS +from snapcraft_legacy.plugins.v1 import PluginV1 +from snapcraft_legacy.plugins.v2 import PluginV2 +from tests.legacy import unit class NonLocalTest(unit.TestCase): @@ -49,8 +49,8 @@ def test_local_plugin(self): print( dedent( """\ - import snapcraft.plugins.v1 - class Local(snapcraft.plugins.v1.PluginV1): + import snapcraft_legacy.plugins.v1 + class Local(snapcraft_legacy.plugins.v1.PluginV1): pass """ ), diff --git a/tests/unit/pluginhandler/test_pluginhandler.py b/tests/legacy/unit/pluginhandler/test_pluginhandler.py similarity index 97% rename from tests/unit/pluginhandler/test_pluginhandler.py rename to tests/legacy/unit/pluginhandler/test_pluginhandler.py index 05e46ef346..78ed9f02b6 100644 --- a/tests/unit/pluginhandler/test_pluginhandler.py +++ b/tests/legacy/unit/pluginhandler/test_pluginhandler.py @@ -26,8 +26,8 @@ import pytest from testtools.matchers import Contains, Equals, FileExists, Not -import snapcraft -from snapcraft.internal import ( +import snapcraft_legacy +from snapcraft_legacy.internal import ( common, errors, lifecycle, @@ -37,9 +37,9 @@ states, steps, ) -from snapcraft.internal.sources.errors import SnapcraftSourceUnhandledError -from snapcraft.project import Project -from tests import fixture_setup, unit +from snapcraft_legacy.internal.sources.errors import SnapcraftSourceUnhandledError +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit from . import mocks @@ -119,7 +119,7 @@ def test_fileset_include_excludes(self): ) self.assertThat(exclude, Equals(["etc", "usr/lib/*.a"])) - @patch.object(snapcraft.plugins.v1.nil.NilPlugin, "snap_fileset") + @patch.object(snapcraft_legacy.plugins.v1.nil.NilPlugin, "snap_fileset") def test_migratable_fileset_for_no_options_modification(self, mock_snap_fileset): """Making sure migratable_fileset_for() doesn't modify options""" @@ -448,7 +448,7 @@ def test_filesets_excludes_without_relative_paths(self): self.assertThat(raised.message, Equals('path "/abs/exclude" must be relative')) - @patch("snapcraft.internal.pluginhandler._organize_filesets") + @patch("snapcraft_legacy.internal.pluginhandler._organize_filesets") def test_build_organizes(self, mock_organize): handler = self.load_part("test-part") handler.build() @@ -456,9 +456,9 @@ def test_build_organizes(self, mock_organize): "test-part", {}, handler.part_install_dir, False ) - @patch("snapcraft.internal.pluginhandler._organize_filesets") + @patch("snapcraft_legacy.internal.pluginhandler._organize_filesets") def test_update_build_organizes_with_overwrite(self, mock_organize): - class TestPlugin(snapcraft.BasePlugin): + class TestPlugin(snapcraft_legacy.BasePlugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.out_of_source_build = True @@ -836,13 +836,13 @@ def setUp(self): super().setUp() fake_install_build_packages = fixtures.MockPatch( - "snapcraft.internal.lifecycle._runner._install_build_packages", + "snapcraft_legacy.internal.lifecycle._runner._install_build_packages", return_value=list(), ) self.useFixture(fake_install_build_packages) fake_install_build_snaps = fixtures.MockPatch( - "snapcraft.internal.lifecycle._runner._install_build_snaps", + "snapcraft_legacy.internal.lifecycle._runner._install_build_snaps", return_value=list(), ) self.useFixture(fake_install_build_snaps) @@ -1100,7 +1100,7 @@ def test_build_is_dirty_from_options(self): self.handler.is_dirty(steps.BUILD), "Expected build step to be dirty" ) - @patch.object(snapcraft.BasePlugin, "enable_cross_compilation") + @patch.object(snapcraft_legacy.BasePlugin, "enable_cross_compilation") def test_build_is_dirty_from_project(self, mock_enable_cross_compilation): project = Project(target_deb_arch="amd64") self.handler = self.load_part("test-part", project=project) @@ -1157,7 +1157,7 @@ def test_pull_is_dirty_from_options(self): self.handler.is_dirty(steps.PULL), "Expected pull step to be dirty" ) - @patch.object(snapcraft.BasePlugin, "enable_cross_compilation") + @patch.object(snapcraft_legacy.BasePlugin, "enable_cross_compilation") def test_pull_is_dirty_from_project(self, mock_enable_cross_compilation): project = Project(target_deb_arch="amd64") self.handler = self.load_part("test-part", project=project) @@ -1453,13 +1453,13 @@ def test_stage_packages_offline(self): part = self.load_part("offline-test", plugin_name="nil") with patch( - "snapcraft.internal.pluginhandler.PluginHandler._fetch_stage_packages" + "snapcraft_legacy.internal.pluginhandler.PluginHandler._fetch_stage_packages" ) as fetch_stage_packages, patch( - "snapcraft.internal.pluginhandler.PluginHandler._fetch_stage_snaps" + "snapcraft_legacy.internal.pluginhandler.PluginHandler._fetch_stage_snaps" ) as fetch_stage_snaps, patch( - "snapcraft.internal.pluginhandler.PluginHandler._unpack_stage_packages" + "snapcraft_legacy.internal.pluginhandler.PluginHandler._unpack_stage_packages" ) as unpack_stage_packages, patch( - "snapcraft.internal.pluginhandler.PluginHandler._unpack_stage_snaps" + "snapcraft_legacy.internal.pluginhandler.PluginHandler._unpack_stage_snaps" ) as unpack_stage_snaps: part.prepare_pull() diff --git a/tests/unit/pluginhandler/test_runner.py b/tests/legacy/unit/pluginhandler/test_runner.py similarity index 98% rename from tests/unit/pluginhandler/test_runner.py rename to tests/legacy/unit/pluginhandler/test_runner.py index 570b7c99da..c10751450f 100644 --- a/tests/unit/pluginhandler/test_runner.py +++ b/tests/legacy/unit/pluginhandler/test_runner.py @@ -22,9 +22,9 @@ from testtools.matchers import Contains, FileContains, FileExists -from snapcraft.internal import errors -from snapcraft.internal.pluginhandler import _runner -from tests import fixture_setup, unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.pluginhandler import _runner +from tests.legacy import fixture_setup, unit def _fake_pull(): diff --git a/tests/unit/pluginhandler/test_scriptlets.py b/tests/legacy/unit/pluginhandler/test_scriptlets.py similarity index 96% rename from tests/unit/pluginhandler/test_scriptlets.py rename to tests/legacy/unit/pluginhandler/test_scriptlets.py index e612874e9b..d5ac61606e 100644 --- a/tests/unit/pluginhandler/test_scriptlets.py +++ b/tests/legacy/unit/pluginhandler/test_scriptlets.py @@ -25,10 +25,10 @@ from testscenarios.scenarios import multiply_scenarios from testtools.matchers import Equals -from snapcraft import yaml_utils -from snapcraft.internal import errors -from tests import unit -from tests.unit.commands import CommandBaseTestCase +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal import errors +from tests.legacy import unit +from tests.legacy.unit.commands import CommandBaseTestCase class ScriptletCommandsTestCase(CommandBaseTestCase): @@ -63,13 +63,13 @@ def setUp(self): open(os.path.join("src", "version.txt"), "w").write("v1.0") fake_install_build_packages = fixtures.MockPatch( - "snapcraft.internal.lifecycle._runner._install_build_packages", + "snapcraft_legacy.internal.lifecycle._runner._install_build_packages", return_value=list(), ) self.useFixture(fake_install_build_packages) fake_install_build_snaps = fixtures.MockPatch( - "snapcraft.internal.lifecycle._runner._install_build_snaps", + "snapcraft_legacy.internal.lifecycle._runner._install_build_snaps", return_value=list(), ) self.useFixture(fake_install_build_snaps) diff --git a/tests/unit/pluginhandler/test_state.py b/tests/legacy/unit/pluginhandler/test_state.py similarity index 96% rename from tests/unit/pluginhandler/test_state.py rename to tests/legacy/unit/pluginhandler/test_state.py index 9ff997e193..3dbc406ab1 100644 --- a/tests/unit/pluginhandler/test_state.py +++ b/tests/legacy/unit/pluginhandler/test_state.py @@ -21,9 +21,9 @@ import fixtures from testtools.matchers import Contains, Equals -from snapcraft import extractors, plugins -from snapcraft.internal import elf, errors, states, steps -from tests import fixture_setup, unit +from snapcraft_legacy import extractors, plugins +from snapcraft_legacy.internal import elf, errors, states, steps +from tests.legacy import fixture_setup, unit class StateBaseTestCase(unit.TestCase): @@ -32,13 +32,15 @@ def setUp(self): self.get_pull_properties_mock = self.useFixture( fixtures.MockPatch( - "snapcraft.plugins.v1.PluginV1.get_pull_properties", return_value=[] + "snapcraft_legacy.plugins.v1.PluginV1.get_pull_properties", + return_value=[], ) ).mock self.get_build_properties_mock = self.useFixture( fixtures.MockPatch( - "snapcraft.plugins.v1.PluginV1.get_build_properties", return_value=[] + "snapcraft_legacy.plugins.v1.PluginV1.get_build_properties", + return_value=[], ) ).mock @@ -47,13 +49,14 @@ def setUp(self): self.get_elf_files_mock = self.useFixture( fixtures.MockPatch( - "snapcraft.internal.elf.get_elf_files", return_value=frozenset() + "snapcraft_legacy.internal.elf.get_elf_files", return_value=frozenset() ) ).mock self.useFixture( fixtures.MockPatch( - "snapcraft.internal.xattrs.read_origin_stage_package", return_value=None + "snapcraft_legacy.internal.xattrs.read_origin_stage_package", + return_value=None, ) ) @@ -98,7 +101,7 @@ def test_pull_build_packages_with_grammar_properties(self): class StateTestCase(StateBaseTestCase): - @patch("snapcraft.internal.repo.Repo") + @patch("snapcraft_legacy.internal.repo.Repo") def test_pull_state(self, repo_mock): self.assertRaises(errors.NoLatestStepError, self.handler.latest_step) self.assertThat(self.handler.next_step(), Equals(steps.PULL)) @@ -132,7 +135,7 @@ def test_pull_state(self, repo_mock): self.assertTrue(type(state.project_options) is OrderedDict) self.assertTrue("deb_arch" in state.project_options) - @patch("snapcraft.internal.repo.Repo") + @patch("snapcraft_legacy.internal.repo.Repo") def test_pull_state_with_extracted_metadata(self, repo_mock): self.handler = self.load_part( "test_part", @@ -204,7 +207,7 @@ def _fake_extractor(file_path, workdir): files, Equals([os.path.join(self.handler.part_source_dir, "metadata-file")]) ) - @patch("snapcraft.internal.repo.Repo") + @patch("snapcraft_legacy.internal.repo.Repo") def test_pull_state_with_scriptlet_metadata(self, repo_mock): self.handler = self.load_part( "test_part", @@ -759,9 +762,9 @@ def test_prime_state_with_stuff_already_primed(self, mock_copy): self.assertTrue(type(state.project_options) is OrderedDict) self.assertThat(len(state.project_options), Equals(0)) - @patch("snapcraft.internal.elf.ElfFile._extract_attributes") - @patch("snapcraft.internal.elf.ElfFile.load_dependencies") - @patch("snapcraft.internal.pluginhandler._migrate_files") + @patch("snapcraft_legacy.internal.elf.ElfFile._extract_attributes") + @patch("snapcraft_legacy.internal.elf.ElfFile.load_dependencies") + @patch("snapcraft_legacy.internal.pluginhandler._migrate_files") def test_prime_state_with_dependencies( self, mock_migrate_files, mock_load_dependencies, mock_get_symbols ): @@ -830,9 +833,9 @@ def test_prime_state_with_dependencies( self.assertTrue(type(state.project_options) is OrderedDict) self.assertThat(len(state.project_options), Equals(0)) - @patch("snapcraft.internal.elf.ElfFile._extract_attributes") - @patch("snapcraft.internal.elf.ElfFile.load_dependencies") - @patch("snapcraft.internal.pluginhandler._migrate_files") + @patch("snapcraft_legacy.internal.elf.ElfFile._extract_attributes") + @patch("snapcraft_legacy.internal.elf.ElfFile.load_dependencies") + @patch("snapcraft_legacy.internal.pluginhandler._migrate_files") def test_prime_state_missing_libraries( self, mock_migrate_files, mock_load_dependencies, mock_get_symbols ): @@ -888,9 +891,9 @@ def test_prime_state_missing_libraries( # The rest should be considered missing. self.assertThat(state.dependency_paths, Equals({"lib3"})) - @patch("snapcraft.internal.elf.ElfFile._extract_attributes") - @patch("snapcraft.internal.elf.ElfFile.load_dependencies") - @patch("snapcraft.internal.pluginhandler._migrate_files") + @patch("snapcraft_legacy.internal.elf.ElfFile._extract_attributes") + @patch("snapcraft_legacy.internal.elf.ElfFile.load_dependencies") + @patch("snapcraft_legacy.internal.pluginhandler._migrate_files") def test_prime_state_with_shadowed_dependencies( self, mock_migrate_files, mock_load_dependencies, mock_get_symbols ): diff --git a/tests/unit/plugins/v1/ros/__init__.py b/tests/legacy/unit/plugins/__init__.py similarity index 100% rename from tests/unit/plugins/v1/ros/__init__.py rename to tests/legacy/unit/plugins/__init__.py diff --git a/tests/unit/plugins/v1/__init__.py b/tests/legacy/unit/plugins/v1/__init__.py similarity index 87% rename from tests/unit/plugins/v1/__init__.py rename to tests/legacy/unit/plugins/v1/__init__.py index 663dc28647..d33ccf5213 100644 --- a/tests/unit/plugins/v1/__init__.py +++ b/tests/legacy/unit/plugins/v1/__init__.py @@ -14,9 +14,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.meta.snap import Snap -from snapcraft.project import Project -from tests import unit +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.project import Project +from tests.legacy import unit class PluginsV1BaseTestCase(unit.TestCase): diff --git a/tests/unit/plugins/v1/conftest.py b/tests/legacy/unit/plugins/v1/conftest.py similarity index 78% rename from tests/unit/plugins/v1/conftest.py rename to tests/legacy/unit/plugins/v1/conftest.py index 744a6794f4..15f9fa9069 100644 --- a/tests/unit/plugins/v1/conftest.py +++ b/tests/legacy/unit/plugins/v1/conftest.py @@ -18,8 +18,8 @@ import pytest -from snapcraft.internal.meta.snap import Snap -from snapcraft.project import Project +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.project import Project @pytest.fixture @@ -37,7 +37,7 @@ def project(monkeypatch, tmp_work_path, request): @pytest.fixture def mock_common_run_output(): """A no-op common.run_output mock.""" - patcher = mock.patch("snapcraft.internal.common.run_output") + patcher = mock.patch("snapcraft_legacy.internal.common.run_output") yield patcher.start() patcher.stop() @@ -45,7 +45,7 @@ def mock_common_run_output(): @pytest.fixture def mock_run(): """A no-op run mock.""" - patcher = mock.patch("snapcraft.plugins.v1.PluginV1.run") + patcher = mock.patch("snapcraft_legacy.plugins.v1.PluginV1.run") yield patcher.start() patcher.stop() @@ -53,7 +53,7 @@ def mock_run(): @pytest.fixture def mock_run_output(): """A no-op run_output mock.""" - patcher = mock.patch("snapcraft.plugins.v1.PluginV1.run_output") + patcher = mock.patch("snapcraft_legacy.plugins.v1.PluginV1.run_output") yield patcher.start() patcher.stop() @@ -61,7 +61,7 @@ def mock_run_output(): @pytest.fixture def mock_tar(): """A no-op tar source mock.""" - patcher = mock.patch("snapcraft.internal.sources.Tar") + patcher = mock.patch("snapcraft_legacy.internal.sources.Tar") yield patcher.start() patcher.stop() @@ -69,6 +69,6 @@ def mock_tar(): @pytest.fixture def mock_zip(): """A no-op zip source mock.""" - patcher = mock.patch("snapcraft.internal.sources.Zip") + patcher = mock.patch("snapcraft_legacy.internal.sources.Zip") yield patcher.start() patcher.stop() diff --git a/tests/unit/project_loader/extensions/__init__.py b/tests/legacy/unit/plugins/v1/python/__init__.py similarity index 100% rename from tests/unit/project_loader/extensions/__init__.py rename to tests/legacy/unit/plugins/v1/python/__init__.py diff --git a/tests/unit/plugins/v1/python/_basesuite.py b/tests/legacy/unit/plugins/v1/python/_basesuite.py similarity index 97% rename from tests/unit/plugins/v1/python/_basesuite.py rename to tests/legacy/unit/plugins/v1/python/_basesuite.py index f153add58b..6ce4a483f8 100644 --- a/tests/unit/plugins/v1/python/_basesuite.py +++ b/tests/legacy/unit/plugins/v1/python/_basesuite.py @@ -15,7 +15,7 @@ # along with this program. If not, see . import os -from tests import unit +from tests.legacy import unit # LP: #1733584 diff --git a/tests/unit/plugins/v1/python/test_errors.py b/tests/legacy/unit/plugins/v1/python/test_errors.py similarity index 96% rename from tests/unit/plugins/v1/python/test_errors.py rename to tests/legacy/unit/plugins/v1/python/test_errors.py index 430a43482c..194e107ed2 100644 --- a/tests/unit/plugins/v1/python/test_errors.py +++ b/tests/legacy/unit/plugins/v1/python/test_errors.py @@ -15,7 +15,7 @@ # along with this program. If not, see . -from snapcraft.plugins.v1._python import errors +from snapcraft_legacy.plugins.v1._python import errors class TestErrorFormatting: diff --git a/tests/unit/plugins/v1/python/test_pip.py b/tests/legacy/unit/plugins/v1/python/test_pip.py similarity index 99% rename from tests/unit/plugins/v1/python/test_pip.py rename to tests/legacy/unit/plugins/v1/python/test_pip.py index dc4a70858d..a524026720 100644 --- a/tests/unit/plugins/v1/python/test_pip.py +++ b/tests/legacy/unit/plugins/v1/python/test_pip.py @@ -23,7 +23,7 @@ import pytest from testtools.matchers import Contains, Equals, HasLength -from snapcraft.plugins.v1._python import _pip, errors +from snapcraft_legacy.plugins.v1._python import _pip, errors from ._basesuite import PythonBaseTestCase @@ -32,11 +32,11 @@ class PipRunBaseTestCase(PythonBaseTestCase): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.internal.common.run_output") + patcher = mock.patch("snapcraft_legacy.internal.common.run_output") self.mock_run_output = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.mock_run = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/python/test_python_finder.py b/tests/legacy/unit/plugins/v1/python/test_python_finder.py similarity index 99% rename from tests/unit/plugins/v1/python/test_python_finder.py rename to tests/legacy/unit/plugins/v1/python/test_python_finder.py index 0a7e52633f..a58a3f6b3e 100644 --- a/tests/unit/plugins/v1/python/test_python_finder.py +++ b/tests/legacy/unit/plugins/v1/python/test_python_finder.py @@ -20,7 +20,7 @@ from testtools.matchers import Equals, MatchesRegex -from snapcraft.plugins.v1._python import _python_finder, errors +from snapcraft_legacy.plugins.v1._python import _python_finder, errors from ._basesuite import PythonBaseTestCase diff --git a/tests/unit/plugins/v1/python/test_sitecustomize.py b/tests/legacy/unit/plugins/v1/python/test_sitecustomize.py similarity index 99% rename from tests/unit/plugins/v1/python/test_sitecustomize.py rename to tests/legacy/unit/plugins/v1/python/test_sitecustomize.py index 6b6465b8b7..a75aaf7dde 100644 --- a/tests/unit/plugins/v1/python/test_sitecustomize.py +++ b/tests/legacy/unit/plugins/v1/python/test_sitecustomize.py @@ -19,7 +19,7 @@ from testtools.matchers import Contains, FileContains -from snapcraft.plugins.v1 import _python +from snapcraft_legacy.plugins.v1 import _python from ._basesuite import PythonBaseTestCase diff --git a/tests/unit/project_loader/grammar/__init__.py b/tests/legacy/unit/plugins/v1/ros/__init__.py similarity index 100% rename from tests/unit/project_loader/grammar/__init__.py rename to tests/legacy/unit/plugins/v1/ros/__init__.py diff --git a/tests/unit/plugins/v1/ros/test_rosdep.py b/tests/legacy/unit/plugins/v1/ros/test_rosdep.py similarity index 97% rename from tests/unit/plugins/v1/ros/test_rosdep.py rename to tests/legacy/unit/plugins/v1/ros/test_rosdep.py index a7932ccaca..4fbf3b1589 100644 --- a/tests/unit/plugins/v1/ros/test_rosdep.py +++ b/tests/legacy/unit/plugins/v1/ros/test_rosdep.py @@ -21,15 +21,15 @@ from testtools.matchers import Equals -import snapcraft -from snapcraft.plugins.v1._ros import rosdep -from tests import unit +import snapcraft_legacy +from snapcraft_legacy.plugins.v1._ros import rosdep +from tests.legacy import unit class RosdepTestCase(unit.TestCase): def setUp(self): super().setUp() - self.project = snapcraft.ProjectOptions() + self.project = snapcraft_legacy.ProjectOptions() self.rosdep = rosdep.Rosdep( ros_distro="melodic", @@ -41,7 +41,7 @@ def setUp(self): target_arch=self.project._get_stage_packages_target_arch(), ) - patcher = mock.patch("snapcraft.repo.Ubuntu") + patcher = mock.patch("snapcraft_legacy.repo.Ubuntu") self.ubuntu_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/ros/test_wstool.py b/tests/legacy/unit/plugins/v1/ros/test_wstool.py similarity index 95% rename from tests/unit/plugins/v1/ros/test_wstool.py rename to tests/legacy/unit/plugins/v1/ros/test_wstool.py index 0c6925178a..799dd91107 100644 --- a/tests/unit/plugins/v1/ros/test_wstool.py +++ b/tests/legacy/unit/plugins/v1/ros/test_wstool.py @@ -19,22 +19,23 @@ import subprocess from unittest import mock +import fixtures from testtools.matchers import Contains, Equals -import snapcraft -from snapcraft.plugins.v1._ros import wstool -from tests import unit +import snapcraft_legacy +from snapcraft_legacy.plugins.v1._ros import wstool +from tests.legacy import unit class WstoolTestCase(unit.TestCase): def setUp(self): super().setUp() - self.project = snapcraft.ProjectOptions() + self.project = snapcraft_legacy.ProjectOptions() self.wstool = wstool.Wstool( "package_path", "wstool_path", self.project, "core18" ) - patcher = mock.patch("snapcraft.repo.Ubuntu") + patcher = mock.patch("snapcraft_legacy.repo.Ubuntu") self.ubuntu_mock = patcher.start() self.addCleanup(patcher.stop) @@ -172,6 +173,8 @@ def test_run(self): # properly. os.makedirs(os.path.join(wstool._wstool_install_path, "lib")) + self.useFixture(fixtures.EnvironmentVariable("LD_LIBRARY_PATH", None)) + wstool._run(["init"]) class check_env: diff --git a/tests/unit/plugins/v1/test_ant.py b/tests/legacy/unit/plugins/v1/test_ant.py similarity index 96% rename from tests/unit/plugins/v1/test_ant.py rename to tests/legacy/unit/plugins/v1/test_ant.py index 95ce5c3d08..26848997c8 100644 --- a/tests/unit/plugins/v1/test_ant.py +++ b/tests/legacy/unit/plugins/v1/test_ant.py @@ -22,11 +22,11 @@ import pytest from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.plugins.v1 import ant -from snapcraft.project import Project -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.plugins.v1 import ant +from snapcraft_legacy.project import Project +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -229,10 +229,10 @@ class Options: self.options = Options() self.run_mock = self.useFixture( - fixtures.MockPatch("snapcraft.internal.common.run") + fixtures.MockPatch("snapcraft_legacy.internal.common.run") ).mock self.tar_mock = self.useFixture( - fixtures.MockPatch("snapcraft.internal.sources.Tar") + fixtures.MockPatch("snapcraft_legacy.internal.sources.Tar") ).mock def create_assets(self, plugin): diff --git a/tests/unit/plugins/v1/test_autotools.py b/tests/legacy/unit/plugins/v1/test_autotools.py similarity index 98% rename from tests/unit/plugins/v1/test_autotools.py rename to tests/legacy/unit/plugins/v1/test_autotools.py index 5d4f4ba191..d443a2b607 100644 --- a/tests/unit/plugins/v1/test_autotools.py +++ b/tests/legacy/unit/plugins/v1/test_autotools.py @@ -23,9 +23,9 @@ import pytest from testtools.matchers import Equals, HasLength -import snapcraft -from snapcraft.internal import errors, meta -from snapcraft.plugins.v1 import autotools, make +import snapcraft_legacy +from snapcraft_legacy.internal import errors, meta +from snapcraft_legacy.plugins.v1 import autotools, make from . import PluginsV1BaseTestCase @@ -444,9 +444,9 @@ def test_unsupported_base(self): ) @mock.patch.object(autotools.AutotoolsPlugin, "run") def test_cross_compile(mock_run, monkeypatch, project, options, deb_arch, triplet): - monkeypatch.setattr(snapcraft.project.Project, "is_cross_compiling", True) + monkeypatch.setattr(snapcraft_legacy.project.Project, "is_cross_compiling", True) - project = snapcraft.project.Project(target_deb_arch=deb_arch) + project = snapcraft_legacy.project.Project(target_deb_arch=deb_arch) project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = autotools.AutotoolsPlugin("test-part", options, project) diff --git a/tests/unit/plugins/v1/test_base.py b/tests/legacy/unit/plugins/v1/test_base.py similarity index 67% rename from tests/unit/plugins/v1/test_base.py rename to tests/legacy/unit/plugins/v1/test_base.py index d22cc999fb..890d30670e 100644 --- a/tests/unit/plugins/v1/test_base.py +++ b/tests/legacy/unit/plugins/v1/test_base.py @@ -18,58 +18,64 @@ from testtools.matchers import Equals -import snapcraft -from snapcraft.internal import errors -from tests import unit +import snapcraft_legacy +from snapcraft_legacy.internal import errors +from tests.legacy import unit class TestBasePlugin(unit.TestCase): def setUp(self): super().setUp() - self.project_options = snapcraft.ProjectOptions() + self.project_options = snapcraft_legacy.ProjectOptions() def test_cross_compilation_raises(self): options = unit.MockOptions(disable_parallel=True) - plugin = snapcraft.BasePlugin("test_plugin", options, self.project_options) + plugin = snapcraft_legacy.BasePlugin( + "test_plugin", options, self.project_options + ) self.assertRaises( errors.CrossCompilationNotSupported, plugin.enable_cross_compilation ) def test_parallel_build_count_returns_1_when_disabled(self): options = unit.MockOptions(disable_parallel=True) - plugin = snapcraft.BasePlugin("test_plugin", options, self.project_options) + plugin = snapcraft_legacy.BasePlugin( + "test_plugin", options, self.project_options + ) self.assertThat(plugin.parallel_build_count, Equals(1)) def test_parallel_build_count_returns_build_count_from_project(self): options = unit.MockOptions(disable_parallel=False) - plugin = snapcraft.BasePlugin("test_plugin", options, self.project_options) + plugin = snapcraft_legacy.BasePlugin( + "test_plugin", options, self.project_options + ) unittest.mock.patch.object(self.project_options, "parallel_build_count", 2) self.assertThat(plugin.parallel_build_count, Equals(2)) - @unittest.mock.patch("snapcraft.internal.common.run") + @unittest.mock.patch("snapcraft_legacy.internal.common.run") def test_run_without_specifying_cwd(self, mock_run): - plugin = snapcraft.BasePlugin("test/part", options=None) + plugin = snapcraft_legacy.BasePlugin("test/part", options=None) plugin.run(["ls"]) mock_run.assert_called_once_with(["ls"], cwd=plugin.builddir) - @unittest.mock.patch("snapcraft.internal.common.run") + @unittest.mock.patch("snapcraft_legacy.internal.common.run") def test_run_specifying_a_cwd(self, mock_run): - plugin = snapcraft.BasePlugin("test/part", options=None) + plugin = snapcraft_legacy.BasePlugin("test/part", options=None) plugin.run(["ls"], cwd=plugin.sourcedir) mock_run.assert_called_once_with(["ls"], cwd=plugin.sourcedir) - @unittest.mock.patch("snapcraft.internal.common.run_output") + @unittest.mock.patch("snapcraft_legacy.internal.common.run_output") def test_run_output_without_specifying_cwd(self, mock_run): - plugin = snapcraft.BasePlugin("test/part", options=None) + plugin = snapcraft_legacy.BasePlugin("test/part", options=None) plugin.run_output(["ls"]) mock_run.assert_called_once_with(["ls"], cwd=plugin.builddir) - @unittest.mock.patch("snapcraft.internal.common.run_output") + @unittest.mock.patch("snapcraft_legacy.internal.common.run_output") def test_run_output_specifying_a_cwd(self, mock_run): - plugin = snapcraft.BasePlugin("test/part", options=None) + plugin = snapcraft_legacy.BasePlugin("test/part", options=None) plugin.run_output(["ls"], cwd=plugin.sourcedir) mock_run.assert_called_once_with(["ls"], cwd=plugin.sourcedir) diff --git a/tests/unit/plugins/v1/test_catkin.py b/tests/legacy/unit/plugins/v1/test_catkin.py similarity index 98% rename from tests/unit/plugins/v1/test_catkin.py rename to tests/legacy/unit/plugins/v1/test_catkin.py index 82ae4b8b6f..824e5d137e 100644 --- a/tests/unit/plugins/v1/test_catkin.py +++ b/tests/legacy/unit/plugins/v1/test_catkin.py @@ -36,11 +36,11 @@ Not, ) -import snapcraft -from snapcraft import repo -from snapcraft.internal import errors -from snapcraft.plugins.v1 import _ros, catkin -from tests import unit +import snapcraft_legacy +from snapcraft_legacy import repo +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import _ros, catkin +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -70,29 +70,30 @@ class props: self.ros_version = "1" self.ubuntu_distro = "bionic" - patcher = mock.patch("snapcraft.repo.Ubuntu") + patcher = mock.patch("snapcraft_legacy.repo.Ubuntu") self.ubuntu_mock = patcher.start() self.addCleanup(patcher.stop) patcher = mock.patch( - "snapcraft.plugins.v1.catkin._find_system_dependencies", return_value={} + "snapcraft_legacy.plugins.v1.catkin._find_system_dependencies", + return_value={}, ) self.dependencies_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.plugins.v1._ros.rosdep.Rosdep") + patcher = mock.patch("snapcraft_legacy.plugins.v1._ros.rosdep.Rosdep") self.rosdep_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.plugins.v1.catkin._Catkin") + patcher = mock.patch("snapcraft_legacy.plugins.v1.catkin._Catkin") self.catkin_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.plugins.v1._ros.wstool.Wstool") + patcher = mock.patch("snapcraft_legacy.plugins.v1._ros.wstool.Wstool") self.wstool_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.plugins.v1._python.Pip") + patcher = mock.patch("snapcraft_legacy.plugins.v1._python.Pip") self.pip_mock = patcher.start() self.addCleanup(patcher.stop) self.pip_mock.return_value.list.return_value = {} @@ -1494,7 +1495,7 @@ class TestBuildArgs: ), ] - @mock.patch("snapcraft.plugins.v1.catkin.CatkinPlugin.run", autospec=True) + @mock.patch("snapcraft_legacy.plugins.v1.catkin.CatkinPlugin.run", autospec=True) @mock.patch.object(catkin.CatkinPlugin, "run_output", return_value="foo") @mock.patch.object(catkin.CatkinPlugin, "_prepare_build") @mock.patch.object(catkin.CatkinPlugin, "_finish_build") @@ -2118,13 +2119,13 @@ class CatkinFindTestCase(unit.TestCase): def setUp(self): super().setUp() - self.project = snapcraft.project.Project() + self.project = snapcraft_legacy.project.Project() self.project._snap_meta.build_base = "core18" self.catkin = catkin._Catkin( "kinetic", "workspace_path", "catkin_path", self.project ) - patcher = mock.patch("snapcraft.repo.Ubuntu") + patcher = mock.patch("snapcraft_legacy.repo.Ubuntu") self.ubuntu_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_catkin_tools.py b/tests/legacy/unit/plugins/v1/test_catkin_tools.py similarity index 97% rename from tests/unit/plugins/v1/test_catkin_tools.py rename to tests/legacy/unit/plugins/v1/test_catkin_tools.py index 005fdba877..b57a0e200e 100644 --- a/tests/unit/plugins/v1/test_catkin_tools.py +++ b/tests/legacy/unit/plugins/v1/test_catkin_tools.py @@ -19,7 +19,7 @@ import pytest -from snapcraft.plugins.v1 import catkin_tools +from snapcraft_legacy.plugins.v1 import catkin_tools from . import PluginsV1BaseTestCase @@ -42,7 +42,7 @@ class props: self.properties = props() - patcher = mock.patch("snapcraft.plugins.v1._python.Pip") + patcher = mock.patch("snapcraft_legacy.plugins.v1._python.Pip") self.pip_mock = patcher.start() self.addCleanup(patcher.stop) self.pip_mock.return_value.list.return_value = {} @@ -52,7 +52,7 @@ class CatkinToolsPluginTestCase(CatkinToolsPluginBaseTest): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.repo.Ubuntu") + patcher = mock.patch("snapcraft_legacy.repo.Ubuntu") self.ubuntu_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_cmake.py b/tests/legacy/unit/plugins/v1/test_cmake.py similarity index 97% rename from tests/unit/plugins/v1/test_cmake.py rename to tests/legacy/unit/plugins/v1/test_cmake.py index 9a6f99c558..b026a07e3c 100644 --- a/tests/unit/plugins/v1/test_cmake.py +++ b/tests/legacy/unit/plugins/v1/test_cmake.py @@ -19,9 +19,9 @@ from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import cmake -from tests import fixture_setup, unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import cmake +from tests.legacy import fixture_setup, unit from . import PluginsV1BaseTestCase @@ -40,7 +40,7 @@ class Options: self.options = Options() - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.run_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_colcon.py b/tests/legacy/unit/plugins/v1/test_colcon.py similarity index 98% rename from tests/unit/plugins/v1/test_colcon.py rename to tests/legacy/unit/plugins/v1/test_colcon.py index e30d93a447..3eacba69a0 100644 --- a/tests/unit/plugins/v1/test_colcon.py +++ b/tests/legacy/unit/plugins/v1/test_colcon.py @@ -24,10 +24,10 @@ from testscenarios import multiply_scenarios from testtools.matchers import Contains, Equals, FileExists, HasLength, LessThan, Not -from snapcraft import repo -from snapcraft.internal import errors -from snapcraft.plugins.v1 import _ros, colcon -from tests import unit +from snapcraft_legacy import repo +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import _ros, colcon +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -53,21 +53,22 @@ class props: self.ubuntu_distro = "bionic" self.ubuntu_mock = self.useFixture( - fixtures.MockPatch("snapcraft.repo.Ubuntu") + fixtures.MockPatch("snapcraft_legacy.repo.Ubuntu") ).mock self.dependencies_mock = self.useFixture( fixtures.MockPatch( - "snapcraft.plugins.v1.colcon._find_system_dependencies", return_value={} + "snapcraft_legacy.plugins.v1.colcon._find_system_dependencies", + return_value={}, ) ).mock self.rosdep_mock = self.useFixture( - fixtures.MockPatch("snapcraft.plugins.v1._ros.rosdep.Rosdep") + fixtures.MockPatch("snapcraft_legacy.plugins.v1._ros.rosdep.Rosdep") ).mock self.pip_mock = self.useFixture( - fixtures.MockPatch("snapcraft.plugins.v1._python.Pip") + fixtures.MockPatch("snapcraft_legacy.plugins.v1._python.Pip") ).mock self.pip_mock.return_value.list.return_value = {} @@ -537,7 +538,7 @@ def setUp(self): super().setUp() self.plugin = colcon.ColconPlugin("test-part", self.properties, self.project) - @mock.patch("snapcraft.internal.mangling.rewrite_python_shebangs") + @mock.patch("snapcraft_legacy.internal.mangling.rewrite_python_shebangs") def test_in_snap_python_is_used(self, shebangs_mock): # Mangling has its own tests. Here we just need to make sure # _prepare_build actually uses it. @@ -676,7 +677,7 @@ def setUp(self): super().setUp() self.plugin = colcon.ColconPlugin("test-part", self.properties, self.project) - @mock.patch("snapcraft.internal.mangling.rewrite_python_shebangs") + @mock.patch("snapcraft_legacy.internal.mangling.rewrite_python_shebangs") def test_in_snap_python_is_used(self, shebangs_mock): # Mangling has its own tests. Here we just need to make sure # _prepare_build actually uses it. diff --git a/tests/unit/plugins/v1/test_conda.py b/tests/legacy/unit/plugins/v1/test_conda.py similarity index 98% rename from tests/unit/plugins/v1/test_conda.py rename to tests/legacy/unit/plugins/v1/test_conda.py index ba64cef4b6..870ecb2c36 100644 --- a/tests/unit/plugins/v1/test_conda.py +++ b/tests/legacy/unit/plugins/v1/test_conda.py @@ -21,9 +21,9 @@ import pytest from testtools.matchers import DirExists, Equals, HasLength, Not -from snapcraft.internal import errors -from snapcraft.plugins.v1 import conda -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import conda +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -224,7 +224,9 @@ def test_pull(self): class Options: conda_miniconda_version = "latest" - fake_source_script = fixtures.MockPatch("snapcraft.internal.sources.Script") + fake_source_script = fixtures.MockPatch( + "snapcraft_legacy.internal.sources.Script" + ) self.useFixture(fake_source_script) plugin = conda.CondaPlugin("test-part", Options(), self.project) diff --git a/tests/unit/plugins/v1/test_crystal.py b/tests/legacy/unit/plugins/v1/test_crystal.py similarity index 95% rename from tests/unit/plugins/v1/test_crystal.py rename to tests/legacy/unit/plugins/v1/test_crystal.py index 211eb0a816..f60f188e97 100644 --- a/tests/unit/plugins/v1/test_crystal.py +++ b/tests/legacy/unit/plugins/v1/test_crystal.py @@ -20,9 +20,9 @@ import fixtures from testtools.matchers import Equals, FileExists, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import crystal -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import crystal +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -31,7 +31,7 @@ class CrystalPluginBaseTest(PluginsV1BaseTestCase): def setUp(self): super().setUp() - self.fake_run = fixtures.MockPatch("snapcraft.internal.common.run") + self.fake_run = fixtures.MockPatch("snapcraft_legacy.internal.common.run") self.useFixture(self.fake_run) @@ -155,7 +155,7 @@ class Options: # fake binaries being built self.useFixture( fixtures.MockPatch( - "snapcraft.internal.elf.ElfFile", side_effect=MockElfFile + "snapcraft_legacy.internal.elf.ElfFile", side_effect=MockElfFile ) ) binaries = ["foo", "bar"] @@ -189,7 +189,7 @@ class Options: # fake binaries being built self.useFixture( fixtures.MockPatch( - "snapcraft.internal.elf.ElfFile", side_effect=MockElfFile + "snapcraft_legacy.internal.elf.ElfFile", side_effect=MockElfFile ) ) binaries = ["foo", "bar"] diff --git a/tests/unit/plugins/v1/test_dotnet.py b/tests/legacy/unit/plugins/v1/test_dotnet.py similarity index 95% rename from tests/unit/plugins/v1/test_dotnet.py rename to tests/legacy/unit/plugins/v1/test_dotnet.py index 304f8922ce..f60407c775 100644 --- a/tests/unit/plugins/v1/test_dotnet.py +++ b/tests/legacy/unit/plugins/v1/test_dotnet.py @@ -21,11 +21,11 @@ from testtools.matchers import Contains, DirExists, Equals, FileExists, Not -import snapcraft -from snapcraft import file_utils -from snapcraft.internal import sources -from snapcraft.plugins.v1 import dotnet -from tests import unit +import snapcraft_legacy +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import sources +from snapcraft_legacy.plugins.v1 import dotnet +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -70,7 +70,7 @@ class Options: # Only amd64 is supported for now. patcher = mock.patch( - "snapcraft.ProjectOptions.deb_arch", + "snapcraft_legacy.ProjectOptions.deb_arch", new_callable=mock.PropertyMock, return_value="amd64", ) @@ -117,8 +117,8 @@ def read(self): urlopen_mock.side_effect = fake_urlopen self.addCleanup(patcher.stop) - original_check_call = snapcraft.internal.common.run - patcher = mock.patch("snapcraft.internal.common.run") + original_check_call = snapcraft_legacy.internal.common.run + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.mock_check_call = patcher.start() self.addCleanup(patcher.stop) @@ -177,7 +177,7 @@ def test_sdk_in_path(self): def test_init_with_non_amd64_architecture(self): with mock.patch( - "snapcraft.ProjectOptions.deb_arch", + "snapcraft_legacy.ProjectOptions.deb_arch", new_callable=mock.PropertyMock, return_value="non-amd64", ): diff --git a/tests/unit/plugins/v1/test_dump.py b/tests/legacy/unit/plugins/v1/test_dump.py similarity index 96% rename from tests/unit/plugins/v1/test_dump.py rename to tests/legacy/unit/plugins/v1/test_dump.py index 3a92d1c0cb..4b9d8fbd28 100644 --- a/tests/unit/plugins/v1/test_dump.py +++ b/tests/legacy/unit/plugins/v1/test_dump.py @@ -18,15 +18,15 @@ from testtools.matchers import Equals -import snapcraft -from snapcraft.plugins.v1.dump import DumpInvalidSymlinkError, DumpPlugin -from tests import unit +import snapcraft_legacy +from snapcraft_legacy.plugins.v1.dump import DumpInvalidSymlinkError, DumpPlugin +from tests.legacy import unit class DumpPluginTestCase(unit.TestCase): def setUp(self): super().setUp() - self.project_options = snapcraft.ProjectOptions() + self.project_options = snapcraft_legacy.ProjectOptions() class Options: source = "." @@ -169,7 +169,7 @@ def test_dump_symlinks_to_libc(self): # Even though this symlink is absolute, since it's to libc the copy # plugin shouldn't try to follow it or modify it. - libc_libs = snapcraft.repo.Repo.get_package_libraries("libc6") + libc_libs = snapcraft_legacy.repo.Repo.get_package_libraries("libc6") # We don't care which lib we're testing with, as long as it's a .so. libc_library_path = [lib for lib in libc_libs if ".so" in lib][0] diff --git a/tests/unit/plugins/v1/test_flutter.py b/tests/legacy/unit/plugins/v1/test_flutter.py similarity index 95% rename from tests/unit/plugins/v1/test_flutter.py rename to tests/legacy/unit/plugins/v1/test_flutter.py index ad2bb9f629..187923bb5d 100644 --- a/tests/unit/plugins/v1/test_flutter.py +++ b/tests/legacy/unit/plugins/v1/test_flutter.py @@ -19,10 +19,10 @@ import pytest -from snapcraft.internal import errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.plugins.v1 import flutter -from snapcraft.project import Project +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.plugins.v1 import flutter +from snapcraft_legacy.project import Project def test_schema(): diff --git a/tests/unit/plugins/v1/test_go.py b/tests/legacy/unit/plugins/v1/test_go.py similarity index 98% rename from tests/unit/plugins/v1/test_go.py rename to tests/legacy/unit/plugins/v1/test_go.py index 8e47f3badd..0786f654a4 100644 --- a/tests/unit/plugins/v1/test_go.py +++ b/tests/legacy/unit/plugins/v1/test_go.py @@ -23,10 +23,10 @@ import pytest from testtools.matchers import Contains, DirExists, Equals, HasLength, Not -from snapcraft.internal import errors, meta -from snapcraft.plugins.v1 import go -from snapcraft.project import Project -from tests import fixture_setup, unit +from snapcraft_legacy.internal import errors, meta +from snapcraft_legacy.plugins.v1 import go +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit from . import PluginsV1BaseTestCase @@ -47,13 +47,13 @@ def fake_go_build(command, cwd, *args, **kwargs): fake_run = self.useFixture( fixtures.MockPatch( - "snapcraft.internal.common.run", side_effect=fake_go_build + "snapcraft_legacy.internal.common.run", side_effect=fake_go_build ) ) self.run_mock = fake_run.mock fake_run_output = self.useFixture( - fixtures.MockPatch("snapcraft.internal.common.run_output") + fixtures.MockPatch("snapcraft_legacy.internal.common.run_output") ) self.run_output_mock = fake_run_output.mock @@ -716,7 +716,7 @@ class Options: ), ) - @mock.patch("snapcraft.internal.elf.ElfFile") + @mock.patch("snapcraft_legacy.internal.elf.ElfFile") def test_build_classic_dynamic_relink(self, mock_elffile): class Options: source = "" @@ -762,7 +762,7 @@ class Options: self.assert_go_paths(plugin) - @mock.patch("snapcraft.internal.elf.ElfFile") + @mock.patch("snapcraft_legacy.internal.elf.ElfFile") def test_build_go_mod_classic_dynamic_relink(self, mock_elffile): class Options: source = "" diff --git a/tests/unit/plugins/v1/test_godeps.py b/tests/legacy/unit/plugins/v1/test_godeps.py similarity index 98% rename from tests/unit/plugins/v1/test_godeps.py rename to tests/legacy/unit/plugins/v1/test_godeps.py index 31ce03a4e1..a443f0bf4a 100644 --- a/tests/unit/plugins/v1/test_godeps.py +++ b/tests/legacy/unit/plugins/v1/test_godeps.py @@ -19,9 +19,9 @@ from testtools.matchers import Contains, Equals, HasLength, Not -from snapcraft.internal import errors -from snapcraft.plugins.v1 import godeps -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import godeps +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -30,7 +30,7 @@ class GodepsPluginBaseTest(PluginsV1BaseTestCase): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.run_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_gradle.py b/tests/legacy/unit/plugins/v1/test_gradle.py similarity index 98% rename from tests/unit/plugins/v1/test_gradle.py rename to tests/legacy/unit/plugins/v1/test_gradle.py index a9a51f3028..8f701b3e92 100644 --- a/tests/unit/plugins/v1/test_gradle.py +++ b/tests/legacy/unit/plugins/v1/test_gradle.py @@ -20,10 +20,10 @@ import pytest -from snapcraft.internal import errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.plugins.v1 import gradle -from snapcraft.project import Project +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.plugins.v1 import gradle +from snapcraft_legacy.project import Project from . import PluginsV1BaseTestCase diff --git a/tests/unit/plugins/v1/test_kbuild.py b/tests/legacy/unit/plugins/v1/test_kbuild.py similarity index 97% rename from tests/unit/plugins/v1/test_kbuild.py rename to tests/legacy/unit/plugins/v1/test_kbuild.py index ef2e832149..bb22941405 100644 --- a/tests/unit/plugins/v1/test_kbuild.py +++ b/tests/legacy/unit/plugins/v1/test_kbuild.py @@ -22,9 +22,9 @@ import pytest from testtools.matchers import Equals, HasLength -import snapcraft -from snapcraft.internal import errors, meta -from snapcraft.plugins.v1 import kbuild +import snapcraft_legacy +from snapcraft_legacy.internal import errors, meta +from snapcraft_legacy.plugins.v1 import kbuild from . import PluginsV1BaseTestCase @@ -299,7 +299,7 @@ def test_unsupported_base(self): @pytest.mark.parametrize("deb_arch", ["armhf", "arm64", "i386", "ppc64el"]) @mock.patch("subprocess.check_call") def test_cross_compile(mock_check_call, monkeypatch, mock_run, deb_arch): - monkeypatch.setattr(snapcraft.project.Project, "is_cross_compiling", True) + monkeypatch.setattr(snapcraft_legacy.project.Project, "is_cross_compiling", True) class Options: build_parameters = [] @@ -309,7 +309,7 @@ class Options: kconfigs = [] build_attributes = [] - project = snapcraft.project.Project(target_deb_arch=deb_arch) + project = snapcraft_legacy.project.Project(target_deb_arch=deb_arch) project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kbuild.KBuildPlugin("test-part", Options(), project) diff --git a/tests/unit/plugins/v1/test_kernel.py b/tests/legacy/unit/plugins/v1/test_kernel.py similarity index 95% rename from tests/unit/plugins/v1/test_kernel.py rename to tests/legacy/unit/plugins/v1/test_kernel.py index 17835d16db..0a2ab8d69c 100644 --- a/tests/unit/plugins/v1/test_kernel.py +++ b/tests/legacy/unit/plugins/v1/test_kernel.py @@ -25,10 +25,10 @@ import pytest from testtools.matchers import Contains, Equals, FileContains, HasLength -import snapcraft -from snapcraft import storeapi -from snapcraft.internal import errors, meta -from snapcraft.plugins.v1 import kernel +import snapcraft_legacy +from snapcraft_legacy import storeapi +from snapcraft_legacy.internal import errors, meta +from snapcraft_legacy.plugins.v1 import kernel from . import PluginsV1BaseTestCase @@ -65,7 +65,7 @@ class Options: self.run_output_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.BasePlugin.build") + patcher = mock.patch("snapcraft_legacy.BasePlugin.build") self.base_build_mock = patcher.start() self.addCleanup(patcher.stop) @@ -133,7 +133,7 @@ def test_get_build_properties(self): ] resulting_build_properties = kernel.KernelPlugin.get_build_properties() expected_build_properties.extend( - snapcraft.plugins.v1.kbuild.KBuildPlugin.get_build_properties() + snapcraft_legacy.plugins.v1.kbuild.KBuildPlugin.get_build_properties() ) self.assertThat( @@ -360,7 +360,7 @@ def test_pack_initrd_modules_return_same_deps(self): [mock.call(modprobe_cmd + ["vfat"], env=mock.ANY)] ) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigfile(self): self.options.kconfigfile = "config" with open(self.options.kconfigfile, "w") as f: @@ -405,7 +405,7 @@ def test_build_with_kconfigfile(self): self.assertThat(config_contents, Equals("ACCEPT=y\n")) self._assert_common_assets(plugin.installdir) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_verbose_with_kconfigfile(self): fake_logger = fixtures.FakeLogger(level=logging.DEBUG) self.useFixture(fake_logger) @@ -476,7 +476,7 @@ def test_build_verbose_with_kconfigfile(self): self.assertThat(config_contents, Equals("ACCEPT=y\n")) self._assert_common_assets(plugin.installdir) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_check_config(self): fake_logger = fixtures.FakeLogger(level=logging.WARNING) self.useFixture(fake_logger) @@ -501,7 +501,7 @@ def test_check_config(self): for warn in required_opts: self.assertIn("CONFIG_{}".format(warn), fake_logger.output) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_check_initrd(self): fake_logger = fixtures.FakeLogger(level=logging.WARNING) self.useFixture(fake_logger) @@ -521,7 +521,7 @@ def test_check_initrd(self): for module in kernel.required_boot: self.assertIn("CONFIG_{}".format(module.upper()), fake_logger.output) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigfile_and_kconfigs(self): self.options.kconfigfile = "config" self.options.kconfigs = ["SOMETHING=y", "ACCEPT=n"] @@ -576,7 +576,7 @@ def test_build_with_kconfigfile_and_kconfigs(self): self.assertThat(config_contents, Equals(expected_config)) self._assert_common_assets(plugin.installdir) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_defconfig_and_kconfigs(self): self.options.kdefconfig = ["defconfig"] self.options.kconfigs = ["SOMETHING=y", "ACCEPT=n"] @@ -638,7 +638,7 @@ def fake_defconfig(*args, **kwargs): self.assertThat(config_contents, Equals(expected_config)) self._assert_common_assets(plugin.installdir) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_two_defconfigs(self): self.options.kdefconfig = ["defconfig", "defconfig2"] @@ -686,7 +686,7 @@ def fake_defconfig(*args, **kwargs): self.assertTrue(os.path.exists(config_file)) self._assert_common_assets(plugin.installdir) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigfile_and_dtbs(self): self.options.kconfigfile = "config" with open(self.options.kconfigfile, "w") as f: @@ -753,7 +753,7 @@ def test_build_with_kconfigfile_and_dtbs_not_found(self): str(raised), Equals("No match for dtb 'fake-dtb.dtb' was found") ) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigfile_and_modules(self): self.options.kconfigfile = "config" with open(self.options.kconfigfile, "w") as f: @@ -854,7 +854,7 @@ def __eq__(self, other): self.assertThat(config_contents, Equals("ACCEPT=y\n")) self._assert_common_assets(plugin.installdir) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigfile_and_firmware(self): self.options.kconfigfile = "config" with open(self.options.kconfigfile, "w") as f: @@ -916,7 +916,7 @@ def fake_unpack(*args, **kwargs): os.path.exists(os.path.join(plugin.installdir, "firmware", "fake-fw-dir")) ) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigfile_and_no_firmware(self): self.options.kconfigfile = "config" with open(self.options.kconfigfile, "w") as f: @@ -958,7 +958,7 @@ def test_build_with_kconfigfile_and_no_firmware(self): config_file = os.path.join(plugin.builddir, ".config") self.assertTrue(os.path.exists(config_file)) - @mock.patch.object(snapcraft.ProjectOptions, "kernel_arch", new="not_arm") + @mock.patch.object(snapcraft_legacy.ProjectOptions, "kernel_arch", new="not_arm") def test_build_with_kconfigflavour(self): arch = self.project.deb_arch branch = "master" @@ -1109,7 +1109,7 @@ def test_build_with_missing_system_map_fails(self): ) def test_enable_cross_compilation(self): - project = snapcraft.project.Project(target_deb_arch="arm64") + project = snapcraft_legacy.project.Project(target_deb_arch="arm64") project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", self.options, project) @@ -1131,7 +1131,7 @@ def test_enable_cross_compilation(self): ) def test_override_cross_compile(self): - project = snapcraft.project.Project(target_deb_arch="arm64") + project = snapcraft_legacy.project.Project(target_deb_arch="arm64") project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", self.options, project) @@ -1156,7 +1156,7 @@ def test_override_cross_compile(self): ) def test_override_cross_compile_empty(self): - project = snapcraft.project.Project(target_deb_arch="arm64") + project = snapcraft_legacy.project.Project(target_deb_arch="arm64") project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", self.options, project) @@ -1180,7 +1180,7 @@ def test_override_cross_compile_empty(self): def test_kernel_image_target_as_map(self): self.options.kernel_image_target = {"arm64": "Image"} - project = snapcraft.project.Project(target_deb_arch="arm64") + project = snapcraft_legacy.project.Project(target_deb_arch="arm64") project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", self.options, project) @@ -1189,7 +1189,7 @@ def test_kernel_image_target_as_map(self): def test_kernel_image_target_as_string(self): self.options.kernel_image_target = "Image" - project = snapcraft.project.Project(target_deb_arch="arm64") + project = snapcraft_legacy.project.Project(target_deb_arch="arm64") project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", self.options, project) @@ -1208,7 +1208,7 @@ class Options: kernel_device_trees = [] kernel_initrd_compression = "gz" - project = snapcraft.project.Project(target_deb_arch="arm64") + project = snapcraft_legacy.project.Project(target_deb_arch="arm64") project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", self.options, project) @@ -1266,7 +1266,7 @@ class Options: kernel_device_trees = [] kernel_initrd_compression = "gz" - project = snapcraft.project.Project(target_deb_arch=deb_arch) + project = snapcraft_legacy.project.Project(target_deb_arch=deb_arch) project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = kernel.KernelPlugin("test-part", Options(), project) diff --git a/tests/unit/plugins/v1/test_make.py b/tests/legacy/unit/plugins/v1/test_make.py similarity index 97% rename from tests/unit/plugins/v1/test_make.py rename to tests/legacy/unit/plugins/v1/test_make.py index a61036ff52..e600a5297e 100644 --- a/tests/unit/plugins/v1/test_make.py +++ b/tests/legacy/unit/plugins/v1/test_make.py @@ -19,8 +19,8 @@ from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import make +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import make from . import PluginsV1BaseTestCase @@ -217,8 +217,8 @@ def test_build_empty_install_var(self, run_mock): ) @mock.patch.object(make.MakePlugin, "run") - @mock.patch("snapcraft.file_utils.link_or_copy_tree") - @mock.patch("snapcraft.file_utils.link_or_copy") + @mock.patch("snapcraft_legacy.file_utils.link_or_copy_tree") + @mock.patch("snapcraft_legacy.file_utils.link_or_copy") def test_build_artifacts(self, link_or_copy_mock, link_or_copy_tree_mock, run_mock): self.options.artifacts = ["dir_artifact", "file_artifact"] plugin = make.MakePlugin("test-part", self.options, self.project) diff --git a/tests/unit/plugins/v1/test_maven.py b/tests/legacy/unit/plugins/v1/test_maven.py similarity index 98% rename from tests/unit/plugins/v1/test_maven.py rename to tests/legacy/unit/plugins/v1/test_maven.py index 0c08feaf87..f126a15caf 100644 --- a/tests/unit/plugins/v1/test_maven.py +++ b/tests/legacy/unit/plugins/v1/test_maven.py @@ -26,11 +26,11 @@ import pytest from testtools.matchers import Equals, FileExists, HasLength -from snapcraft.internal import errors -from snapcraft.internal.meta.snap import Snap -from snapcraft.plugins.v1 import maven -from snapcraft.project import Project -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.internal.meta.snap import Snap +from snapcraft_legacy.plugins.v1 import maven +from snapcraft_legacy.project import Project +from tests.legacy import unit from . import PluginsV1BaseTestCase @@ -276,11 +276,11 @@ class Options: self.options = Options() - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.run_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.sources.Tar") + patcher = mock.patch("snapcraft_legacy.sources.Tar") self.tar_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_meson.py b/tests/legacy/unit/plugins/v1/test_meson.py similarity index 98% rename from tests/unit/plugins/v1/test_meson.py rename to tests/legacy/unit/plugins/v1/test_meson.py index a2c670b468..7ddd9742fc 100644 --- a/tests/unit/plugins/v1/test_meson.py +++ b/tests/legacy/unit/plugins/v1/test_meson.py @@ -20,9 +20,9 @@ from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import meson -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import meson +from tests.legacy import unit from . import PluginsV1BaseTestCase diff --git a/tests/unit/plugins/v1/test_nil.py b/tests/legacy/unit/plugins/v1/test_nil.py similarity index 92% rename from tests/unit/plugins/v1/test_nil.py rename to tests/legacy/unit/plugins/v1/test_nil.py index f11288b0b6..1633d12e6b 100644 --- a/tests/unit/plugins/v1/test_nil.py +++ b/tests/legacy/unit/plugins/v1/test_nil.py @@ -16,8 +16,8 @@ from testtools.matchers import Equals -from snapcraft.plugins.v1.nil import NilPlugin -from tests import unit +from snapcraft_legacy.plugins.v1.nil import NilPlugin +from tests.legacy import unit class TestNilPlugin(unit.TestCase): diff --git a/tests/unit/plugins/v1/test_nodejs.py b/tests/legacy/unit/plugins/v1/test_nodejs.py similarity index 98% rename from tests/unit/plugins/v1/test_nodejs.py rename to tests/legacy/unit/plugins/v1/test_nodejs.py index 3fa83f7043..d14fb0fcde 100644 --- a/tests/unit/plugins/v1/test_nodejs.py +++ b/tests/legacy/unit/plugins/v1/test_nodejs.py @@ -25,9 +25,9 @@ import pytest from testtools.matchers import Equals, FileExists, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import nodejs -from tests import fixture_setup, unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import nodejs +from tests.legacy import fixture_setup, unit from . import PluginsV1BaseTestCase @@ -48,16 +48,16 @@ class Options: # always have a package.json stub under source open("package.json", "w").close() - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.run_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.internal.common.run_output") + patcher = mock.patch("snapcraft_legacy.internal.common.run_output") self.run_output_mock = patcher.start() self.addCleanup(patcher.stop) self.run_output_mock.return_value = '{"dependencies": []}' - patcher = mock.patch("snapcraft.sources.Tar") + patcher = mock.patch("snapcraft_legacy.sources.Tar") self.tar_mock = patcher.start() self.addCleanup(patcher.stop) @@ -708,7 +708,7 @@ def test_get_nodejs_release(self, deb_arch, engine, expected_url): class NodePluginUnsupportedArchTest(NodePluginBaseTest): - @mock.patch("snapcraft.project.Project.deb_arch", "ppcel64") + @mock.patch("snapcraft_legacy.project.Project.deb_arch", "ppcel64") def test_unsupported_arch_raises_exception(self): raised = self.assertRaises( errors.SnapcraftEnvironmentError, diff --git a/tests/unit/plugins/v1/test_plainbox_provider.py b/tests/legacy/unit/plugins/v1/test_plainbox_provider.py similarity index 98% rename from tests/unit/plugins/v1/test_plainbox_provider.py rename to tests/legacy/unit/plugins/v1/test_plainbox_provider.py index 948d61d881..09309705b1 100644 --- a/tests/unit/plugins/v1/test_plainbox_provider.py +++ b/tests/legacy/unit/plugins/v1/test_plainbox_provider.py @@ -19,9 +19,9 @@ from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import plainbox_provider -from tests import fixture_setup, unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import plainbox_provider +from tests.legacy import fixture_setup, unit from . import PluginsV1BaseTestCase diff --git a/tests/unit/plugins/v1/test_python.py b/tests/legacy/unit/plugins/v1/test_python.py similarity index 99% rename from tests/unit/plugins/v1/test_python.py rename to tests/legacy/unit/plugins/v1/test_python.py index d519518fd6..df3ffd2663 100644 --- a/tests/unit/plugins/v1/test_python.py +++ b/tests/legacy/unit/plugins/v1/test_python.py @@ -21,9 +21,9 @@ import jsonschema from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import python -from tests import fixture_setup, unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import python +from tests.legacy import fixture_setup, unit from . import PluginsV1BaseTestCase @@ -71,7 +71,7 @@ class Options: self.options = Options() - patcher = mock.patch("snapcraft.plugins.v1._python.Pip") + patcher = mock.patch("snapcraft_legacy.plugins.v1._python.Pip") self.mock_pip = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_qmake.py b/tests/legacy/unit/plugins/v1/test_qmake.py similarity index 98% rename from tests/unit/plugins/v1/test_qmake.py rename to tests/legacy/unit/plugins/v1/test_qmake.py index f8b0de522a..2f450c01a1 100644 --- a/tests/unit/plugins/v1/test_qmake.py +++ b/tests/legacy/unit/plugins/v1/test_qmake.py @@ -19,8 +19,8 @@ from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import qmake +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import qmake from . import PluginsV1BaseTestCase @@ -29,7 +29,7 @@ class QMakeTestCase(PluginsV1BaseTestCase): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.run_mock = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/plugins/v1/test_ruby.py b/tests/legacy/unit/plugins/v1/test_ruby.py similarity index 94% rename from tests/unit/plugins/v1/test_ruby.py rename to tests/legacy/unit/plugins/v1/test_ruby.py index 13ce7df700..82295aef01 100644 --- a/tests/unit/plugins/v1/test_ruby.py +++ b/tests/legacy/unit/plugins/v1/test_ruby.py @@ -19,9 +19,9 @@ from testtools.matchers import Equals, HasLength -import snapcraft -from snapcraft.internal import errors -from snapcraft.plugins.v1 import ruby +import snapcraft_legacy +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import ruby from . import PluginsV1BaseTestCase @@ -30,7 +30,7 @@ class RubyPluginTestCase(PluginsV1BaseTestCase): def setUp(self): super().setUp() - class Options(snapcraft.ProjectOptions): + class Options(snapcraft_legacy.ProjectOptions): source = "." ruby_version = "2.4.2" gems = [] @@ -126,7 +126,7 @@ def test_env_with_multiple_ruby(self): os.makedirs(os.path.join(part_dir, "lib", "ruby", "gems", "test-version2")) error = self.assertRaises( - snapcraft.internal.errors.SnapcraftEnvironmentError, + snapcraft_legacy.internal.errors.SnapcraftEnvironmentError, plugin.env, "test-part-path", ) @@ -149,7 +149,7 @@ def test_env_with_rbconfigs(self): open(os.path.join(real_arch_libdir1, "rbconfig.rb"), "w").close() error = self.assertRaises( - snapcraft.internal.errors.SnapcraftEnvironmentError, + snapcraft_legacy.internal.errors.SnapcraftEnvironmentError, plugin.env, "test-part-path", ) @@ -185,7 +185,7 @@ def test_pull_installs_ruby(self): with mock.patch.multiple( plugin, _ruby_tar=mock.DEFAULT, _gem_install=mock.DEFAULT ) as mocks: - with mock.patch("snapcraft.internal.common.run") as mock_run: + with mock.patch("snapcraft_legacy.internal.common.run") as mock_run: plugin.pull() ruby_expected_dir = os.path.join(self.path, "parts", "test-part", "ruby") @@ -218,7 +218,7 @@ def test_pull_installs_gems_without_bundler(self): with mock.patch.multiple( plugin, _ruby_tar=mock.DEFAULT, _ruby_install=mock.DEFAULT ): - with mock.patch("snapcraft.internal.common.run") as mock_run: + with mock.patch("snapcraft_legacy.internal.common.run") as mock_run: plugin.pull() test_part_dir = os.path.join(self.path, "parts", "test-part") @@ -243,7 +243,7 @@ def test_pull_with_bundler(self): with mock.patch.multiple( plugin, _ruby_tar=mock.DEFAULT, _ruby_install=mock.DEFAULT ): - with mock.patch("snapcraft.internal.common.run") as mock_run: + with mock.patch("snapcraft_legacy.internal.common.run") as mock_run: plugin.pull() test_part_dir = os.path.join(self.path, "parts", "test-part") mock_run.assert_has_calls( diff --git a/tests/unit/plugins/v1/test_rust.py b/tests/legacy/unit/plugins/v1/test_rust.py similarity index 97% rename from tests/unit/plugins/v1/test_rust.py rename to tests/legacy/unit/plugins/v1/test_rust.py index c06da3734e..5ecab1b939 100644 --- a/tests/unit/plugins/v1/test_rust.py +++ b/tests/legacy/unit/plugins/v1/test_rust.py @@ -25,10 +25,10 @@ import toml from testtools.matchers import Contains, Equals, FileExists, Not -import snapcraft -from snapcraft.internal import errors, meta -from snapcraft.plugins.v1 import rust -from tests import fixture_setup, unit +import snapcraft_legacy +from snapcraft_legacy.internal import errors, meta +from snapcraft_legacy.plugins.v1 import rust +from tests.legacy import fixture_setup, unit from . import PluginsV1BaseTestCase @@ -49,11 +49,11 @@ class Options: self.options = Options() - patcher = mock.patch("snapcraft.internal.common.run") + patcher = mock.patch("snapcraft_legacy.internal.common.run") self.run_mock = patcher.start() self.addCleanup(patcher.stop) - patcher = mock.patch("snapcraft.internal.common.run_output") + patcher = mock.patch("snapcraft_legacy.internal.common.run_output") patcher.start() self.addCleanup(patcher.stop) @@ -150,7 +150,7 @@ class TestRustPluginCrossCompile: ("s390x", dict(deb_arch="s390x", target="s390x-unknown-linux-gnu")), ] - @mock.patch("snapcraft.internal.sources._script.Script.download") + @mock.patch("snapcraft_legacy.internal.sources._script.Script.download") def test_cross_compile( self, mock_download, @@ -162,8 +162,10 @@ def test_cross_compile( deb_arch, target, ): - monkeypatch.setattr(snapcraft.project.Project, "is_cross_compiling", True) - project = snapcraft.project.Project(target_deb_arch=deb_arch) + monkeypatch.setattr( + snapcraft_legacy.project.Project, "is_cross_compiling", True + ) + project = snapcraft_legacy.project.Project(target_deb_arch=deb_arch) project._snap_meta = meta.snap.Snap(name="test-snap", base="core18") plugin = rust.RustPlugin("test-part", options, project) @@ -493,7 +495,7 @@ def test_pull_with_source_and_source_subdir(self, script_mock): ] ) - @mock.patch("snapcraft.ProjectOptions.deb_arch", "fantasy-arch") + @mock.patch("snapcraft_legacy.ProjectOptions.deb_arch", "fantasy-arch") def test_unsupported_target_arch_raises_exception(self): self.assertRaises(errors.SnapcraftEnvironmentError, self.plugin._get_target) diff --git a/tests/unit/plugins/v1/test_scons.py b/tests/legacy/unit/plugins/v1/test_scons.py similarity index 97% rename from tests/unit/plugins/v1/test_scons.py rename to tests/legacy/unit/plugins/v1/test_scons.py index 3c2fb8d231..30a513e373 100644 --- a/tests/unit/plugins/v1/test_scons.py +++ b/tests/legacy/unit/plugins/v1/test_scons.py @@ -19,9 +19,9 @@ from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors -from snapcraft.plugins.v1 import scons -from tests import unit +from snapcraft_legacy.internal import errors +from snapcraft_legacy.plugins.v1 import scons +from tests.legacy import unit from . import PluginsV1BaseTestCase diff --git a/tests/unit/plugins/v1/test_waf.py b/tests/legacy/unit/plugins/v1/test_waf.py similarity index 97% rename from tests/unit/plugins/v1/test_waf.py rename to tests/legacy/unit/plugins/v1/test_waf.py index 4e8556f985..15ac64b4e5 100644 --- a/tests/unit/plugins/v1/test_waf.py +++ b/tests/legacy/unit/plugins/v1/test_waf.py @@ -20,10 +20,10 @@ import pytest from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors, meta -from snapcraft.plugins.v1 import waf -from snapcraft.project import Project -from tests import unit +from snapcraft_legacy.internal import errors, meta +from snapcraft_legacy.plugins.v1 import waf +from snapcraft_legacy.project import Project +from tests.legacy import unit from . import PluginsV1BaseTestCase diff --git a/tests/unit/plugins/v2/test_autotools.py b/tests/legacy/unit/plugins/v2/test_autotools.py similarity index 97% rename from tests/unit/plugins/v2/test_autotools.py rename to tests/legacy/unit/plugins/v2/test_autotools.py index d0412cd52e..21a1f89132 100644 --- a/tests/unit/plugins/v2/test_autotools.py +++ b/tests/legacy/unit/plugins/v2/test_autotools.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.plugins.v2.autotools import AutotoolsPlugin +from snapcraft_legacy.plugins.v2.autotools import AutotoolsPlugin def test_schema(): diff --git a/tests/unit/plugins/v2/test_catkin.py b/tests/legacy/unit/plugins/v2/test_catkin.py similarity index 98% rename from tests/unit/plugins/v2/test_catkin.py rename to tests/legacy/unit/plugins/v2/test_catkin.py index 1702876521..7dccfa94c3 100644 --- a/tests/unit/plugins/v2/test_catkin.py +++ b/tests/legacy/unit/plugins/v2/test_catkin.py @@ -17,8 +17,8 @@ import os import sys -import snapcraft.plugins.v2._ros as _ros -import snapcraft.plugins.v2.catkin as catkin +import snapcraft_legacy.plugins.v2._ros as _ros +import snapcraft_legacy.plugins.v2.catkin as catkin def test_schema(): diff --git a/tests/unit/plugins/v2/test_catkin_tools.py b/tests/legacy/unit/plugins/v2/test_catkin_tools.py similarity index 98% rename from tests/unit/plugins/v2/test_catkin_tools.py rename to tests/legacy/unit/plugins/v2/test_catkin_tools.py index 28a62669dd..9ba0984302 100644 --- a/tests/unit/plugins/v2/test_catkin_tools.py +++ b/tests/legacy/unit/plugins/v2/test_catkin_tools.py @@ -17,8 +17,8 @@ import os import sys -import snapcraft.plugins.v2._ros as _ros -import snapcraft.plugins.v2.catkin_tools as catkin_tools +import snapcraft_legacy.plugins.v2._ros as _ros +import snapcraft_legacy.plugins.v2.catkin_tools as catkin_tools def test_schema(): diff --git a/tests/unit/plugins/v2/test_cmake.py b/tests/legacy/unit/plugins/v2/test_cmake.py similarity index 98% rename from tests/unit/plugins/v2/test_cmake.py rename to tests/legacy/unit/plugins/v2/test_cmake.py index 420be23386..16d35de844 100644 --- a/tests/unit/plugins/v2/test_cmake.py +++ b/tests/legacy/unit/plugins/v2/test_cmake.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.plugins.v2.cmake import CMakePlugin +from snapcraft_legacy.plugins.v2.cmake import CMakePlugin def test_schema(): diff --git a/tests/unit/plugins/v2/test_colcon.py b/tests/legacy/unit/plugins/v2/test_colcon.py similarity index 98% rename from tests/unit/plugins/v2/test_colcon.py rename to tests/legacy/unit/plugins/v2/test_colcon.py index d4a78a132b..e4a67ac238 100644 --- a/tests/unit/plugins/v2/test_colcon.py +++ b/tests/legacy/unit/plugins/v2/test_colcon.py @@ -17,8 +17,8 @@ import os import sys -import snapcraft.plugins.v2._ros as _ros -import snapcraft.plugins.v2.colcon as colcon +import snapcraft_legacy.plugins.v2._ros as _ros +import snapcraft_legacy.plugins.v2.colcon as colcon def test_schema(): diff --git a/tests/unit/plugins/v2/test_conda.py b/tests/legacy/unit/plugins/v2/test_conda.py similarity index 98% rename from tests/unit/plugins/v2/test_conda.py rename to tests/legacy/unit/plugins/v2/test_conda.py index d127f465d6..b1046d6cac 100644 --- a/tests/unit/plugins/v2/test_conda.py +++ b/tests/legacy/unit/plugins/v2/test_conda.py @@ -18,9 +18,9 @@ import pytest -from snapcraft.plugins.v2.conda import ( - CondaPlugin, +from snapcraft_legacy.plugins.v2.conda import ( ArchitectureMissing, + CondaPlugin, _get_miniconda_source, ) diff --git a/tests/unit/plugins/v2/test_crystal.py b/tests/legacy/unit/plugins/v2/test_crystal.py similarity index 97% rename from tests/unit/plugins/v2/test_crystal.py rename to tests/legacy/unit/plugins/v2/test_crystal.py index 23845b05c9..c419ed91a8 100644 --- a/tests/unit/plugins/v2/test_crystal.py +++ b/tests/legacy/unit/plugins/v2/test_crystal.py @@ -20,8 +20,8 @@ from testtools import TestCase from testtools.matchers import Equals -import snapcraft.plugins.v2.crystal as crystal -from snapcraft.plugins.v2.crystal import CrystalPlugin +import snapcraft_legacy.plugins.v2.crystal as crystal +from snapcraft_legacy.plugins.v2.crystal import CrystalPlugin class CrystalPluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_dump.py b/tests/legacy/unit/plugins/v2/test_dump.py similarity index 96% rename from tests/unit/plugins/v2/test_dump.py rename to tests/legacy/unit/plugins/v2/test_dump.py index db22a56bad..e991eae4c9 100644 --- a/tests/unit/plugins/v2/test_dump.py +++ b/tests/legacy/unit/plugins/v2/test_dump.py @@ -17,7 +17,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.dump import DumpPlugin +from snapcraft_legacy.plugins.v2.dump import DumpPlugin class DumpPluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_go.py b/tests/legacy/unit/plugins/v2/test_go.py similarity index 98% rename from tests/unit/plugins/v2/test_go.py rename to tests/legacy/unit/plugins/v2/test_go.py index d804fe6809..8b8c3f151b 100644 --- a/tests/unit/plugins/v2/test_go.py +++ b/tests/legacy/unit/plugins/v2/test_go.py @@ -17,7 +17,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.go import GoPlugin +from snapcraft_legacy.plugins.v2.go import GoPlugin class GoPluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_make.py b/tests/legacy/unit/plugins/v2/test_make.py similarity index 98% rename from tests/unit/plugins/v2/test_make.py rename to tests/legacy/unit/plugins/v2/test_make.py index b3fc261f94..58998a26ef 100644 --- a/tests/unit/plugins/v2/test_make.py +++ b/tests/legacy/unit/plugins/v2/test_make.py @@ -17,7 +17,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.make import MakePlugin +from snapcraft_legacy.plugins.v2.make import MakePlugin class MakePluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_meson.py b/tests/legacy/unit/plugins/v2/test_meson.py similarity index 98% rename from tests/unit/plugins/v2/test_meson.py rename to tests/legacy/unit/plugins/v2/test_meson.py index f4737948ff..2eab5717c5 100644 --- a/tests/unit/plugins/v2/test_meson.py +++ b/tests/legacy/unit/plugins/v2/test_meson.py @@ -17,7 +17,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.meson import MesonPlugin +from snapcraft_legacy.plugins.v2.meson import MesonPlugin class MesonPluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_nil.py b/tests/legacy/unit/plugins/v2/test_nil.py similarity index 96% rename from tests/unit/plugins/v2/test_nil.py rename to tests/legacy/unit/plugins/v2/test_nil.py index 8fe0b18526..fd90dd6986 100644 --- a/tests/unit/plugins/v2/test_nil.py +++ b/tests/legacy/unit/plugins/v2/test_nil.py @@ -17,7 +17,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.nil import NilPlugin +from snapcraft_legacy.plugins.v2.nil import NilPlugin class NilPluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_npm.py b/tests/legacy/unit/plugins/v2/test_npm.py similarity index 98% rename from tests/unit/plugins/v2/test_npm.py rename to tests/legacy/unit/plugins/v2/test_npm.py index 565f0451ca..062c2d1b78 100644 --- a/tests/unit/plugins/v2/test_npm.py +++ b/tests/legacy/unit/plugins/v2/test_npm.py @@ -20,7 +20,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.npm import NpmPlugin +from snapcraft_legacy.plugins.v2.npm import NpmPlugin class NpmPluginTest(TestCase): diff --git a/tests/unit/plugins/v2/test_python.py b/tests/legacy/unit/plugins/v2/test_python.py similarity index 98% rename from tests/unit/plugins/v2/test_python.py rename to tests/legacy/unit/plugins/v2/test_python.py index abe5f5f6e5..c56eae0987 100644 --- a/tests/unit/plugins/v2/test_python.py +++ b/tests/legacy/unit/plugins/v2/test_python.py @@ -16,7 +16,7 @@ from textwrap import dedent -from snapcraft.plugins.v2.python import PythonPlugin +from snapcraft_legacy.plugins.v2.python import PythonPlugin def test_schema(): diff --git a/tests/unit/plugins/v2/test_qmake.py b/tests/legacy/unit/plugins/v2/test_qmake.py similarity index 98% rename from tests/unit/plugins/v2/test_qmake.py rename to tests/legacy/unit/plugins/v2/test_qmake.py index 5b13805abc..f7d298906a 100644 --- a/tests/unit/plugins/v2/test_qmake.py +++ b/tests/legacy/unit/plugins/v2/test_qmake.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.plugins.v2.qmake import QMakePlugin +from snapcraft_legacy.plugins.v2.qmake import QMakePlugin def test_schema(): diff --git a/tests/unit/plugins/v2/test_rust.py b/tests/legacy/unit/plugins/v2/test_rust.py similarity index 98% rename from tests/unit/plugins/v2/test_rust.py rename to tests/legacy/unit/plugins/v2/test_rust.py index cd06c9bfd1..d3ab8832b8 100644 --- a/tests/unit/plugins/v2/test_rust.py +++ b/tests/legacy/unit/plugins/v2/test_rust.py @@ -19,7 +19,7 @@ from testtools import TestCase from testtools.matchers import Equals -from snapcraft.plugins.v2.rust import RustPlugin +from snapcraft_legacy.plugins.v2.rust import RustPlugin class RustPluginTest(TestCase): diff --git a/tests/unit/project/__init__.py b/tests/legacy/unit/project/__init__.py similarity index 88% rename from tests/unit/project/__init__.py rename to tests/legacy/unit/project/__init__.py index 48b4a2fc2c..0381a942b5 100644 --- a/tests/unit/project/__init__.py +++ b/tests/legacy/unit/project/__init__.py @@ -13,9 +13,9 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import snapcraft.yaml_utils.errors -from snapcraft.project import Project as _Project -from tests import unit +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.project import Project as _Project +from tests.legacy import unit class ProjectBaseTest(unit.TestCase): @@ -37,6 +37,6 @@ def assertValidationRaises(self, snapcraft_yaml): project = self.make_snapcraft_project(snapcraft_yaml) return self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, project.info.validate_raw_snapcraft, ) diff --git a/tests/unit/project/test_errors.py b/tests/legacy/unit/project/test_errors.py similarity index 98% rename from tests/unit/project/test_errors.py rename to tests/legacy/unit/project/test_errors.py index 3066343d7d..0b664765b2 100644 --- a/tests/unit/project/test_errors.py +++ b/tests/legacy/unit/project/test_errors.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.project import errors +from snapcraft_legacy.project import errors class TestErrorFormatting: diff --git a/tests/unit/project/test_get_snapcraft.py b/tests/legacy/unit/project/test_get_snapcraft.py similarity index 97% rename from tests/unit/project/test_get_snapcraft.py rename to tests/legacy/unit/project/test_get_snapcraft.py index 5e1abbedfa..9d0ce30bb0 100644 --- a/tests/unit/project/test_get_snapcraft.py +++ b/tests/legacy/unit/project/test_get_snapcraft.py @@ -18,7 +18,7 @@ import pytest -from snapcraft.project import errors, get_snapcraft_yaml +from snapcraft_legacy.project import errors, get_snapcraft_yaml @pytest.fixture( diff --git a/tests/unit/project/test_project.py b/tests/legacy/unit/project/test_project.py similarity index 98% rename from tests/unit/project/test_project.py rename to tests/legacy/unit/project/test_project.py index 9d5ab54a09..28ca90b846 100644 --- a/tests/unit/project/test_project.py +++ b/tests/legacy/unit/project/test_project.py @@ -20,7 +20,7 @@ import pytest -from snapcraft.project import Project +from snapcraft_legacy.project import Project def test_project_with_arguments(): diff --git a/tests/unit/project/test_project_info.py b/tests/legacy/unit/project/test_project_info.py similarity index 92% rename from tests/unit/project/test_project_info.py rename to tests/legacy/unit/project/test_project_info.py index 5fa478e993..8b1d5c9713 100644 --- a/tests/unit/project/test_project_info.py +++ b/tests/legacy/unit/project/test_project_info.py @@ -19,9 +19,9 @@ import pytest from testtools.matchers import Equals, Is, MatchesRegex -import snapcraft.yaml_utils.errors -from snapcraft.project._project_info import ProjectInfo -from tests import unit +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.project._project_info import ProjectInfo +from tests.legacy import unit class ProjectInfoTest(unit.TestCase): @@ -49,7 +49,7 @@ def test_empty_yaml(self): snapcraft_yaml_file_path = self.make_snapcraft_yaml("") raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, ProjectInfo, snapcraft_yaml_file_path=snapcraft_yaml_file_path, ) @@ -90,7 +90,7 @@ def test_name_is_required(self): ) raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, ProjectInfo, snapcraft_yaml_file_path=snapcraft_yaml_file_path, ) @@ -167,7 +167,7 @@ def test_tab_in_yaml(self): ) raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, ProjectInfo, snapcraft_yaml_file_path=snapcraft_yaml_file_path, ) @@ -177,7 +177,8 @@ def test_tab_in_yaml(self): self.assertThat( raised.message, MatchesRegex( - "found a tab character that violates indentation " "on line 5, column 1" + "found a tab character that violates? (indentation|intendation)" + " on line 5, column 1" ), ) @@ -194,7 +195,7 @@ def test_invalid_yaml_invalid_unicode_chars(self): ) raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, ProjectInfo, snapcraft_yaml_file_path=snapcraft_yaml_file_path, ) @@ -225,7 +226,7 @@ def test_invalid_yaml_unhashable(self): ) raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, ProjectInfo, snapcraft_yaml_file_path=snapcraft_yaml_file_path, ) @@ -246,7 +247,7 @@ def test_invalid_yaml_list_in_mapping(self): ) raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, ProjectInfo, snapcraft_yaml_file_path=snapcraft_yaml_file_path, ) diff --git a/tests/unit/project/test_sanity_checks.py b/tests/legacy/unit/project/test_sanity_checks.py similarity index 95% rename from tests/unit/project/test_sanity_checks.py rename to tests/legacy/unit/project/test_sanity_checks.py index f0b2287543..b168510b56 100644 --- a/tests/unit/project/test_sanity_checks.py +++ b/tests/legacy/unit/project/test_sanity_checks.py @@ -20,9 +20,9 @@ import pytest -import snapcraft.internal.errors -from snapcraft.project import Project, errors -from snapcraft.project._sanity_checks import conduct_project_sanity_check +import snapcraft_legacy.internal.errors +from snapcraft_legacy.project import Project, errors +from snapcraft_legacy.project._sanity_checks import conduct_project_sanity_check @pytest.fixture @@ -105,7 +105,9 @@ def test_icon(tmp_work_path): ) # Test without icon raises error - with pytest.raises(snapcraft.internal.errors.SnapcraftEnvironmentError) as exc_info: + with pytest.raises( + snapcraft_legacy.internal.errors.SnapcraftEnvironmentError + ) as exc_info: conduct_project_sanity_check(project) assert exc_info.value.get_brief() == "Specified icon 'foo.png' does not exist." diff --git a/tests/unit/project/test_schema.py b/tests/legacy/unit/project/test_schema.py similarity index 92% rename from tests/unit/project/test_schema.py rename to tests/legacy/unit/project/test_schema.py index e67fe7b498..7e6b24f423 100644 --- a/tests/unit/project/test_schema.py +++ b/tests/legacy/unit/project/test_schema.py @@ -22,9 +22,9 @@ from testtools.matchers import Contains, Equals # required for schema format checkers -import snapcraft.internal.project_loader._config # noqa: F401 -import snapcraft.yaml_utils.errors -from snapcraft.project._schema import Validator +import snapcraft_legacy.internal.project_loader._config # noqa: F401 +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.project._schema import Validator from . import ProjectBaseTest @@ -58,7 +58,7 @@ class ValidationTest(ValidationBaseTest): def test_summary_too_long(self): self.data["summary"] = "a" * 80 raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, Validator(self.data).validate, ) @@ -72,7 +72,7 @@ def test_apps_required_properties(self): self.data["apps"] = {"service1": {}} raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, Validator(self.data).validate, ) @@ -87,9 +87,13 @@ def test_schema_file_not_found(self): mock_the_open = mock.mock_open() mock_the_open.side_effect = FileNotFoundError() - with mock.patch("snapcraft.project._schema.open", mock_the_open, create=True): + with mock.patch( + "snapcraft_legacy.project._schema.open", mock_the_open, create=True + ): raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, Validator, self.data + snapcraft_legacy.yaml_utils.errors.YamlValidationError, + Validator, + self.data, ) expected_message = "snapcraft validation file is missing from installation path" @@ -185,7 +189,7 @@ def test_invalid_restart_condition(self): } raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, Validator(self.data).validate, ) @@ -208,7 +212,7 @@ def test_invalid_activates_on(self): }, } raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, Validator(self.data).validate, ) self.assertThat( @@ -223,7 +227,7 @@ def test_missing_required_property_and_missing_adopt_info(self): del self.data["adopt-info"] raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, Validator(self.data).validate, ) @@ -242,7 +246,7 @@ def test_invalid_install_mode(self): } raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, Validator(self.data).validate, ) @@ -266,7 +270,7 @@ def test_invalid_install_mode(self): def test_daemon_dependency(data, option, value): data["apps"] = {"service1": {"command": "binary1", option: value}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() assert str(error.value).endswith( @@ -279,7 +283,7 @@ def test_daemon_dependency(data, option, value): def test_required_properties(data, key): del data[key] - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() assert f"{key!r} is a required property" in str(error.value) @@ -334,7 +338,9 @@ class TestInvalidNames: def test(self, data, name, err): data["name"] = name - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises( + snapcraft_legacy.yaml_utils.errors.YamlValidationError + ) as error: Validator(data).validate() assert str(error.value).endswith( @@ -366,7 +372,7 @@ def test_valid_types(data, snap_type): def test_invalid_types(data, snap_type): data["type"] = snap_type - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError): + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError): Validator(data).validate() @@ -380,7 +386,7 @@ def test_type_base_and_no_base(data): def test_type_base_and_base(data): data["type"] = "base" - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() assert _BASE_TYPE_MSG in str(error.value) @@ -465,7 +471,7 @@ def test_valid_app_names(data, name): def test_invalid_app_names(data, name): data["apps"] = {name: {"command": "1"}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -511,7 +517,7 @@ def test_valid_refresh_modes(data, mode): def test_refresh_mode_daemon_missing_errors(data, mode): data["apps"] = {"service1": {"command": "binary1", "refresh-mode": mode}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError): + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError): Validator(data).validate() @@ -539,7 +545,7 @@ def test_valid_modes(data, mode): def test_daemon_missing_errors(data, mode): data["apps"] = {"service1": {"command": "binary1", "stop-mode": mode}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError): + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError): Validator(data).validate() @@ -574,7 +580,7 @@ def test_daemon_missing_errors(data, mode): def test_invalid_hook_names(data, name): data["hooks"] = {name: {"plugs": ["network"]}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -592,7 +598,7 @@ def test_invalid_hook_names(data, name): def test_invalid_part_names(data, name): data["parts"] = {name: {"plugin": "nil"}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -724,7 +730,9 @@ class TestInvalidArchitectures: def test(self, data, architectures, message): data["architectures"] = architectures - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises( + snapcraft_legacy.yaml_utils.errors.YamlValidationError + ) as error: Validator(data).validate() assert message in str(error.value) @@ -783,7 +791,7 @@ def test_valid_title(data, title): def test_invalid_title(data, title, error_template): data["title"] = title - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() assert _EXPECTED_ERROR_TEMPLATE[error_template].format(title) in str(error.value) @@ -906,7 +914,7 @@ def test_valid_version(data, version): def test_invalid_version(data, version): data["version"] = version - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -924,7 +932,7 @@ def test_invalid_version(data, version): def test_invalid_version_type(data): data["version"] = 0.1 - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -941,7 +949,7 @@ def test_invalid_version_type(data): def test_invalid_version_length(data): data["version"] = "this.is.a.really.too.long.version" - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1043,7 +1051,7 @@ def test_valid_compression(data, compression): def test_invalid_compression(data, compression): data["compression"] = compression - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1064,7 +1072,7 @@ def test_valid_confinement(data, confinement): def test_invalid_confinement(data, confinement): data["confinement"] = confinement - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1085,7 +1093,7 @@ def test_valid_description(data, desc): def test_invalid_description(data, desc): data["description"] = desc - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1106,7 +1114,7 @@ def test_valid_grade(data, grade): def test_invalid_grade(data, grade): data["grade"] = grade - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1144,7 +1152,7 @@ def test_valid_epoch(data, epoch): def test_invalid_epoch(data, epoch): data["epoch"] = epoch - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1163,7 +1171,7 @@ def test_valid_license(data): def test_invalid_license(data): data["license"] = 1234 - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = ( @@ -1184,7 +1192,7 @@ def test_valid_adapter(data, adapter): def test_invalid_adapter(data, adapter): data["apps"] = {"foo": {"command": "foo", "adapter": adapter}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = "The 'apps/foo/adapter' property does not match" @@ -1198,7 +1206,7 @@ def test_invalid_adapter(data, adapter): def test_invalid_part_build_environment_key_type(data, build_environment): data["parts"]["part1"]["build-environment"] = build_environment - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError): + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError): Validator(data).validate() @@ -1208,7 +1216,7 @@ def test_invalid_part_build_environment_key_type(data, build_environment): def test_invalid_command_chain(data, command_chain): data["apps"] = {"foo": {"command": "foo", "command-chain": command_chain}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = "The 'apps/foo/command-chain" @@ -1229,7 +1237,7 @@ def test_yaml_valid_system_usernames_short(data, username): def test_invalid_yaml_invalid_username(data): data["system-usernames"] = {"snap_user": "shared"} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = "The 'system-usernames' property does not match the required schema: 'snap_user' is not a valid system-username." @@ -1238,7 +1246,7 @@ def test_invalid_yaml_invalid_username(data): def test_invalid_yaml_invalid_short_scope(data): data["system-usernames"] = {"snap_daemon": "invalid-scope"} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = "The 'system-usernames/snap_daemon' property does not match the required schema: 'invalid-scope' is not valid under any of the given schemas" @@ -1247,7 +1255,7 @@ def test_invalid_yaml_invalid_short_scope(data): def test_invalid_yaml_invalid_long_scope(data): data["system-usernames"] = {"snap_daemon": {"scope": "invalid-scope"}} - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError) as error: Validator(data).validate() expected_message = "The 'system-usernames/snap_daemon' property does not match the required schema: {'scope': 'invalid-scope'} is not valid under any of the given schemas" @@ -1444,7 +1452,9 @@ class TestInvalidAptConfigurations: def test_invalid(self, data, packages, message_contains): data["package-repositories"] = packages - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError) as error: + with pytest.raises( + snapcraft_legacy.yaml_utils.errors.YamlValidationError + ) as error: Validator(data).validate() assert message_contains in str(error.value) @@ -1493,16 +1503,11 @@ def test_valid_metadata_links(data, contact, donation, issues, source_code, webs @pytest.mark.parametrize( - "contact", - (1, {"mailto:project@acme.com", "team@acme.com"}, None), + "contact", (1, {"mailto:project@acme.com", "team@acme.com"}, None), ) @pytest.mark.parametrize( "donation", - ( - 1, - {"https://paypal.com", "https://cafecito.app", "https://ko-fi.com"}, - None, - ), + (1, {"https://paypal.com", "https://cafecito.app", "https://ko-fi.com"}, None,), ) @pytest.mark.parametrize( "issues", @@ -1526,5 +1531,5 @@ def test_invalid_metadata_links(data, contact, donation, issues, source_code, we data["source-code"] = source_code data["website"] = website - with pytest.raises(snapcraft.yaml_utils.errors.YamlValidationError): + with pytest.raises(snapcraft_legacy.yaml_utils.errors.YamlValidationError): Validator(data).validate() diff --git a/tests/unit/project_loader/__init__.py b/tests/legacy/unit/project_loader/__init__.py similarity index 85% rename from tests/unit/project_loader/__init__.py rename to tests/legacy/unit/project_loader/__init__.py index b609c0f7db..7ddf3769b9 100644 --- a/tests/unit/project_loader/__init__.py +++ b/tests/legacy/unit/project_loader/__init__.py @@ -16,9 +16,9 @@ from unittest import mock -from snapcraft.internal import project_loader -from snapcraft.project import Project as _Project -from tests import unit +from snapcraft_legacy.internal import project_loader +from snapcraft_legacy.project import Project as _Project +from tests.legacy import unit class ProjectLoaderBaseTest(unit.TestCase): @@ -37,7 +37,7 @@ def setUp(self): super().setUp() patcher = mock.patch( - "snapcraft.internal.project_loader._parts_config.PartsConfig.load_part" + "snapcraft_legacy.internal.project_loader._parts_config.PartsConfig.load_part" ) self.mock_load_part = patcher.start() self.addCleanup(patcher.stop) diff --git a/tests/unit/project_loader/grammar_processing/__init__.py b/tests/legacy/unit/project_loader/extensions/__init__.py similarity index 100% rename from tests/unit/project_loader/grammar_processing/__init__.py rename to tests/legacy/unit/project_loader/extensions/__init__.py diff --git a/tests/unit/project_loader/extensions/test_extensions.py b/tests/legacy/unit/project_loader/extensions/test_extensions.py similarity index 96% rename from tests/unit/project_loader/extensions/test_extensions.py rename to tests/legacy/unit/project_loader/extensions/test_extensions.py index 3f2e353a84..10de3b6055 100644 --- a/tests/unit/project_loader/extensions/test_extensions.py +++ b/tests/legacy/unit/project_loader/extensions/test_extensions.py @@ -19,7 +19,10 @@ from testscenarios import multiply_scenarios -from snapcraft.internal.project_loader import find_extension, supported_extension_names +from snapcraft_legacy.internal.project_loader import ( + find_extension, + supported_extension_names, +) from .. import ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/extensions/test_flutter.py b/tests/legacy/unit/project_loader/extensions/test_flutter.py similarity index 95% rename from tests/unit/project_loader/extensions/test_flutter.py rename to tests/legacy/unit/project_loader/extensions/test_flutter.py index 8c07425be2..bbb7b7ecb1 100644 --- a/tests/unit/project_loader/extensions/test_flutter.py +++ b/tests/legacy/unit/project_loader/extensions/test_flutter.py @@ -18,10 +18,10 @@ import pytest -from snapcraft.internal.project_loader._extensions.flutter_dev import ( +from snapcraft_legacy.internal.project_loader._extensions.flutter_dev import ( ExtensionImpl as FlutterDevExtension, ) -from snapcraft.internal.project_loader._extensions.flutter_master import ( +from snapcraft_legacy.internal.project_loader._extensions.flutter_master import ( ExtensionImpl as FlutterMasterExtension, ) diff --git a/tests/unit/project_loader/extensions/test_gnome_3_28.py b/tests/legacy/unit/project_loader/extensions/test_gnome_3_28.py similarity index 97% rename from tests/unit/project_loader/extensions/test_gnome_3_28.py rename to tests/legacy/unit/project_loader/extensions/test_gnome_3_28.py index 717d1fe63f..b54f5aa2d2 100644 --- a/tests/unit/project_loader/extensions/test_gnome_3_28.py +++ b/tests/legacy/unit/project_loader/extensions/test_gnome_3_28.py @@ -16,7 +16,9 @@ from testtools.matchers import Equals -from snapcraft.internal.project_loader._extensions.gnome_3_28 import ExtensionImpl +from snapcraft_legacy.internal.project_loader._extensions.gnome_3_28 import ( + ExtensionImpl, +) from .. import ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/extensions/test_gnome_3_34.py b/tests/legacy/unit/project_loader/extensions/test_gnome_3_34.py similarity index 97% rename from tests/unit/project_loader/extensions/test_gnome_3_34.py rename to tests/legacy/unit/project_loader/extensions/test_gnome_3_34.py index f7c9d94292..b031f6393b 100644 --- a/tests/unit/project_loader/extensions/test_gnome_3_34.py +++ b/tests/legacy/unit/project_loader/extensions/test_gnome_3_34.py @@ -16,8 +16,10 @@ from testtools.matchers import Equals -from snapcraft.internal.project_loader._extensions.gnome_3_34 import ExtensionImpl -from tests.unit.commands import CommandBaseTestCase +from snapcraft_legacy.internal.project_loader._extensions.gnome_3_34 import ( + ExtensionImpl, +) +from tests.legacy.unit.commands import CommandBaseTestCase from .. import ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/extensions/test_gnome_3_38.py b/tests/legacy/unit/project_loader/extensions/test_gnome_3_38.py similarity index 97% rename from tests/unit/project_loader/extensions/test_gnome_3_38.py rename to tests/legacy/unit/project_loader/extensions/test_gnome_3_38.py index 2c287a040c..953ba897e3 100644 --- a/tests/unit/project_loader/extensions/test_gnome_3_38.py +++ b/tests/legacy/unit/project_loader/extensions/test_gnome_3_38.py @@ -16,8 +16,10 @@ from testtools.matchers import Equals -from snapcraft.internal.project_loader._extensions.gnome_3_38 import ExtensionImpl -from tests.unit.commands import CommandBaseTestCase +from snapcraft_legacy.internal.project_loader._extensions.gnome_3_38 import ( + ExtensionImpl, +) +from tests.legacy.unit.commands import CommandBaseTestCase from .. import ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/extensions/test_kde_neon.py b/tests/legacy/unit/project_loader/extensions/test_kde_neon.py similarity index 98% rename from tests/unit/project_loader/extensions/test_kde_neon.py rename to tests/legacy/unit/project_loader/extensions/test_kde_neon.py index 5460187244..3501c3b274 100644 --- a/tests/unit/project_loader/extensions/test_kde_neon.py +++ b/tests/legacy/unit/project_loader/extensions/test_kde_neon.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.project_loader._extensions.kde_neon import ExtensionImpl +from snapcraft_legacy.internal.project_loader._extensions.kde_neon import ExtensionImpl def test_extension_core18(): diff --git a/tests/unit/project_loader/extensions/test_ros1_noetic.py b/tests/legacy/unit/project_loader/extensions/test_ros1_noetic.py similarity index 96% rename from tests/unit/project_loader/extensions/test_ros1_noetic.py rename to tests/legacy/unit/project_loader/extensions/test_ros1_noetic.py index ab5cd90649..1e1b3bc96d 100644 --- a/tests/unit/project_loader/extensions/test_ros1_noetic.py +++ b/tests/legacy/unit/project_loader/extensions/test_ros1_noetic.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.internal.project_loader._extensions.ros1_noetic import ( +from snapcraft_legacy.internal.project_loader._extensions.ros1_noetic import ( ExtensionImpl as Ros1NoeticExtension, ) diff --git a/tests/unit/project_loader/extensions/test_ros2_foxy.py b/tests/legacy/unit/project_loader/extensions/test_ros2_foxy.py similarity index 96% rename from tests/unit/project_loader/extensions/test_ros2_foxy.py rename to tests/legacy/unit/project_loader/extensions/test_ros2_foxy.py index d4d30c6904..91a7dc40d6 100644 --- a/tests/unit/project_loader/extensions/test_ros2_foxy.py +++ b/tests/legacy/unit/project_loader/extensions/test_ros2_foxy.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.internal.project_loader._extensions.ros2_foxy import ( +from snapcraft_legacy.internal.project_loader._extensions.ros2_foxy import ( ExtensionImpl as Ros2FoxyExtension, ) diff --git a/tests/unit/project_loader/extensions/test_utils.py b/tests/legacy/unit/project_loader/extensions/test_utils.py similarity index 98% rename from tests/unit/project_loader/extensions/test_utils.py rename to tests/legacy/unit/project_loader/extensions/test_utils.py index c77a6f79ed..cfd2d51bc4 100644 --- a/tests/unit/project_loader/extensions/test_utils.py +++ b/tests/legacy/unit/project_loader/extensions/test_utils.py @@ -19,10 +19,10 @@ from testtools.matchers import Contains, Equals, Not -import snapcraft.yaml_utils.errors -from snapcraft.internal.project_loader import errors -from snapcraft.internal.project_loader._extensions._extension import Extension -from tests import fixture_setup +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy.internal.project_loader import errors +from snapcraft_legacy.internal.project_loader._extensions._extension import Extension +from tests.legacy import fixture_setup from .. import ProjectLoaderBaseTest @@ -498,7 +498,7 @@ def test_scalars_no_override(self): class InvalidExtensionTest(ExtensionTestBase): def test_invalid_app_extension_format(self): raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, self.make_snapcraft_project, textwrap.dedent( """\ @@ -532,7 +532,7 @@ def test_invalid_app_extension_format(self): def test_duplicate_extensions(self): raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, self.make_snapcraft_project, textwrap.dedent( """\ @@ -566,7 +566,7 @@ def test_duplicate_extensions(self): def test_invalid_extension_is_validated(self): raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, self.make_snapcraft_project, textwrap.dedent( """\ diff --git a/tests/unit/project_loader/inspection/__init__.py b/tests/legacy/unit/project_loader/grammar_processing/__init__.py similarity index 100% rename from tests/unit/project_loader/inspection/__init__.py rename to tests/legacy/unit/project_loader/grammar_processing/__init__.py diff --git a/tests/unit/project_loader/grammar_processing/test_global_grammar_processor.py b/tests/legacy/unit/project_loader/grammar_processing/test_global_grammar_processor.py similarity index 91% rename from tests/unit/project_loader/grammar_processing/test_global_grammar_processor.py rename to tests/legacy/unit/project_loader/grammar_processing/test_global_grammar_processor.py index 603ed53257..097b3ae969 100644 --- a/tests/unit/project_loader/grammar_processing/test_global_grammar_processor.py +++ b/tests/legacy/unit/project_loader/grammar_processing/test_global_grammar_processor.py @@ -16,7 +16,7 @@ import doctest -from snapcraft.internal.project_loader.grammar_processing import ( +from snapcraft_legacy.internal.project_loader.grammar_processing import ( _global_grammar_processor as processor, ) diff --git a/tests/unit/project_loader/grammar_processing/test_part_grammar_processor.py b/tests/legacy/unit/project_loader/grammar_processing/test_part_grammar_processor.py similarity index 85% rename from tests/unit/project_loader/grammar_processing/test_part_grammar_processor.py rename to tests/legacy/unit/project_loader/grammar_processing/test_part_grammar_processor.py index e85d90b39e..61e1a15914 100644 --- a/tests/unit/project_loader/grammar_processing/test_part_grammar_processor.py +++ b/tests/legacy/unit/project_loader/grammar_processing/test_part_grammar_processor.py @@ -20,10 +20,12 @@ from testscenarios import multiply_scenarios -from snapcraft import project -from snapcraft.internal import repo as snapcraft_repo -from snapcraft.internal.project_loader.grammar_processing import PartGrammarProcessor -from snapcraft.internal.project_loader.grammar_processing import ( +from snapcraft_legacy import project +from snapcraft_legacy.internal import repo as snapcraft_repo +from snapcraft_legacy.internal.project_loader.grammar_processing import ( + PartGrammarProcessor, +) +from snapcraft_legacy.internal.project_loader.grammar_processing import ( _part_grammar_processor as processor, ) @@ -196,26 +198,22 @@ class TestPartGrammarSource: ] arch_scenarios = [ - ("amd64", {"host_arch": "x86_64", "target_arch": "amd64"}), - ("i386", {"host_arch": "i686", "target_arch": "i386"}), - ("amd64 to armhf", {"host_arch": "x86_64", "target_arch": "armhf"}), + ("amd64", {"arch": "amd64", "target_arch": "amd64"}), + ("i386", {"arch": "i386", "target_arch": "i386"}), + ("amd64 to armhf", {"arch": "amd64", "target_arch": "armhf"}), ] scenarios = multiply_scenarios(source_scenarios, arch_scenarios) def test( self, - monkeypatch, - host_arch, + arch, target_arch, properties, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - repo = mock.Mock() plugin = mock.Mock() plugin.properties = properties.copy() @@ -229,7 +227,8 @@ def test( PartGrammarProcessor( plugin=plugin, properties=plugin.properties, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ).get_source() == expected_arch[f"expected_{target_arch}"] @@ -308,9 +307,9 @@ class TestPartGrammarBuildAndStageSnaps: ] arch_scenarios = [ - ("amd64", {"host_arch": "x86_64", "target_arch": "amd64"}), - ("i386", {"host_arch": "i686", "target_arch": "i386"}), - ("amd64 to armhf", {"host_arch": "x86_64", "target_arch": "armhf"}), + ("amd64", {"arch": "amd64", "target_arch": "amd64"}), + ("i386", {"arch": "i386", "target_arch": "i386"}), + ("amd64 to armhf", {"arch": "amd64", "target_arch": "armhf"}), ] scenarios = multiply_scenarios(source_scenarios, arch_scenarios) @@ -318,15 +317,13 @@ class TestPartGrammarBuildAndStageSnaps: def test_snaps( self, monkeypatch, - host_arch, + arch, target_arch, snaps, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) monkeypatch.setattr( snapcraft_repo.snaps.SnapPackage, "is_valid_snap", @@ -346,7 +343,8 @@ class Plugin: "build-snaps": {"plugin-preferred"}, "stage-snaps": "plugin-preferred", }, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ) @@ -361,15 +359,13 @@ class Plugin: def test_snaps_no_plugin_attribute( self, monkeypatch, - host_arch, + arch, target_arch, snaps, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) monkeypatch.setattr( snapcraft_repo.snaps.SnapPackage, "is_valid_snap", @@ -385,7 +381,8 @@ class Plugin: processor = PartGrammarProcessor( plugin=plugin, properties={"build-snaps": snaps, "stage-snaps": snaps}, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ) @@ -458,26 +455,22 @@ class TestPartGrammarStagePackages: ] arch_scenarios = [ - ("amd64", {"host_arch": "x86_64", "target_arch": "amd64"}), - ("i386", {"host_arch": "i686", "target_arch": "i386"}), - ("amd64 to armhf", {"host_arch": "x86_64", "target_arch": "armhf"}), + ("amd64", {"arch": "amd64", "target_arch": "amd64"}), + ("i386", {"arch": "i386", "target_arch": "i386"}), + ("amd64 to armhf", {"arch": "amd64", "target_arch": "armhf"}), ] scenarios = multiply_scenarios(source_scenarios, arch_scenarios) def test_packages( self, - monkeypatch, - host_arch, + arch, target_arch, packages, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - repo = mock.Mock() class Plugin: @@ -488,7 +481,8 @@ class Plugin: processor = PartGrammarProcessor( plugin=plugin, properties={"stage-packages": "plugin-preferred"}, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ) @@ -503,17 +497,13 @@ class Plugin: def test_packages_plugin_no_attr( self, - monkeypatch, - host_arch, + arch, target_arch, packages, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - repo = mock.Mock() class Plugin: @@ -523,7 +513,8 @@ class Plugin: processor = PartGrammarProcessor( plugin=plugin, properties={"stage-packages": packages}, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ) @@ -597,26 +588,22 @@ class TestPartGrammarBuildPackages: ] arch_scenarios = [ - ("amd64", {"host_arch": "x86_64", "target_arch": "amd64"}), - ("i386", {"host_arch": "i686", "target_arch": "i386"}), - ("amd64 to armhf", {"host_arch": "x86_64", "target_arch": "armhf"}), + ("amd64", {"arch": "amd64", "target_arch": "amd64"}), + ("i386", {"arch": "i386", "target_arch": "i386"}), + ("amd64 to armhf", {"arch": "amd64", "target_arch": "armhf"}), ] scenarios = multiply_scenarios(source_scenarios, arch_scenarios) def test_packages( self, - monkeypatch, - host_arch, + arch, target_arch, packages, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - repo = mock.Mock() class Plugin: @@ -627,7 +614,8 @@ class Plugin: processor = PartGrammarProcessor( plugin=plugin, properties={"build-packages": {"plugin-preferred"}}, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ) @@ -642,17 +630,13 @@ class Plugin: def test_packages_plugin_no_attr( self, - monkeypatch, - host_arch, + arch, target_arch, packages, expected_amd64, expected_i386, expected_armhf, ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - repo = mock.Mock() class Plugin: @@ -662,7 +646,8 @@ class Plugin: processor = PartGrammarProcessor( plugin=plugin, properties={"build-packages": packages}, - project=project.Project(target_deb_arch=target_arch), + arch=arch, + target_arch=target_arch, repo=repo, ) diff --git a/tests/unit/review_tools/__init__.py b/tests/legacy/unit/project_loader/inspection/__init__.py similarity index 100% rename from tests/unit/review_tools/__init__.py rename to tests/legacy/unit/project_loader/inspection/__init__.py diff --git a/tests/unit/project_loader/inspection/test_latest_step.py b/tests/legacy/unit/project_loader/inspection/test_latest_step.py similarity index 91% rename from tests/unit/project_loader/inspection/test_latest_step.py rename to tests/legacy/unit/project_loader/inspection/test_latest_step.py index 1930f1eaef..a174987bcf 100644 --- a/tests/unit/project_loader/inspection/test_latest_step.py +++ b/tests/legacy/unit/project_loader/inspection/test_latest_step.py @@ -18,10 +18,10 @@ from testtools.matchers import Equals -from snapcraft import project -from snapcraft.internal import steps -from snapcraft.internal.project_loader import inspection -from tests import unit +from snapcraft_legacy import project +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.project_loader import inspection +from tests.legacy import unit class LatestStepTest(unit.TestCase): diff --git a/tests/unit/project_loader/inspection/test_lifecycle_status.py b/tests/legacy/unit/project_loader/inspection/test_lifecycle_status.py similarity index 98% rename from tests/unit/project_loader/inspection/test_lifecycle_status.py rename to tests/legacy/unit/project_loader/inspection/test_lifecycle_status.py index 1bb1387fb0..4c268d2e53 100644 --- a/tests/unit/project_loader/inspection/test_lifecycle_status.py +++ b/tests/legacy/unit/project_loader/inspection/test_lifecycle_status.py @@ -19,8 +19,8 @@ from testtools.matchers import Equals -from snapcraft.internal import steps -from snapcraft.internal.project_loader import inspection +from snapcraft_legacy.internal import steps +from snapcraft_legacy.internal.project_loader import inspection from .. import ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/inspection/test_provides.py b/tests/legacy/unit/project_loader/inspection/test_provides.py similarity index 96% rename from tests/unit/project_loader/inspection/test_provides.py rename to tests/legacy/unit/project_loader/inspection/test_provides.py index d7199f3e07..546497ea45 100644 --- a/tests/unit/project_loader/inspection/test_provides.py +++ b/tests/legacy/unit/project_loader/inspection/test_provides.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -from snapcraft import project -from snapcraft.internal.project_loader import inspection -from tests import unit +from snapcraft_legacy import project +from snapcraft_legacy.internal.project_loader import inspection +from tests.legacy import unit class ProvidesTest(unit.TestCase): diff --git a/tests/unit/project_loader/test_build_packages.py b/tests/legacy/unit/project_loader/test_build_packages.py similarity index 97% rename from tests/unit/project_loader/test_build_packages.py rename to tests/legacy/unit/project_loader/test_build_packages.py index fa0b128917..d1f918168c 100644 --- a/tests/unit/project_loader/test_build_packages.py +++ b/tests/legacy/unit/project_loader/test_build_packages.py @@ -19,8 +19,8 @@ import pytest -from snapcraft.internal import project_loader -from snapcraft.project import Project +from snapcraft_legacy.internal import project_loader +from snapcraft_legacy.project import Project def get_project_config(snapcraft_yaml_content): diff --git a/tests/unit/project_loader/test_build_snaps.py b/tests/legacy/unit/project_loader/test_build_snaps.py similarity index 100% rename from tests/unit/project_loader/test_build_snaps.py rename to tests/legacy/unit/project_loader/test_build_snaps.py diff --git a/tests/unit/project_loader/test_config.py b/tests/legacy/unit/project_loader/test_config.py similarity index 98% rename from tests/unit/project_loader/test_config.py rename to tests/legacy/unit/project_loader/test_config.py index 655b9f62cb..5b32509c3f 100644 --- a/tests/unit/project_loader/test_config.py +++ b/tests/legacy/unit/project_loader/test_config.py @@ -18,9 +18,9 @@ from testtools.matchers import Contains, Equals -import snapcraft.internal.project_loader._config as _config -from snapcraft.internal.project_loader import errors -from tests import unit +import snapcraft_legacy.internal.project_loader._config as _config +from snapcraft_legacy.internal.project_loader import errors +from tests.legacy import unit from . import LoadPartBaseTest, ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/test_environment.py b/tests/legacy/unit/project_loader/test_environment.py similarity index 97% rename from tests/unit/project_loader/test_environment.py rename to tests/legacy/unit/project_loader/test_environment.py index 78436ff6eb..13e330614a 100644 --- a/tests/unit/project_loader/test_environment.py +++ b/tests/legacy/unit/project_loader/test_environment.py @@ -24,9 +24,9 @@ import fixtures from testtools.matchers import Contains, Equals, GreaterThan, Not -import snapcraft -from snapcraft.internal import common -from tests.fixture_setup.os_release import FakeOsRelease +import snapcraft_legacy +from snapcraft_legacy.internal import common +from tests.legacy.fixture_setup.os_release import FakeOsRelease from . import ProjectLoaderBaseTest @@ -95,7 +95,8 @@ def test_config_snap_environment_with_no_library_paths(self): ) @mock.patch.object( - snapcraft.internal.pluginhandler.PluginHandler, "get_primed_dependency_paths" + snapcraft_legacy.internal.pluginhandler.PluginHandler, + "get_primed_dependency_paths", ) def test_config_snap_environment_with_dependencies(self, mock_get_dependencies): library_paths = { @@ -120,7 +121,8 @@ def test_config_snap_environment_with_dependencies(self, mock_get_dependencies): ) @mock.patch.object( - snapcraft.internal.pluginhandler.PluginHandler, "get_primed_dependency_paths" + snapcraft_legacy.internal.pluginhandler.PluginHandler, + "get_primed_dependency_paths", ) def test_config_snap_environment_with_dependencies_but_no_paths( self, mock_get_dependencies @@ -305,7 +307,7 @@ def test_parts_build_env_ordering_with_deps(self): self.useFixture(fixtures.EnvironmentVariable("PATH", "/bin")) - arch_triplet = snapcraft.ProjectOptions().arch_triplet + arch_triplet = snapcraft_legacy.ProjectOptions().arch_triplet self.maxDiff = None paths = [ os.path.join(self.stage_dir, "lib"), @@ -445,7 +447,7 @@ def test_content_dirs_default(self): self.assertThat(env, Contains('SNAPCRAFT_CONTENT_DIRS=""')) @mock.patch( - "snapcraft.project._project.Project._get_provider_content_dirs", + "snapcraft_legacy.project._project.Project._get_provider_content_dirs", return_value=sorted({"/tmp/test1", "/tmp/test2"}), ) def test_content_dirs(self, mock_get_content_dirs): diff --git a/tests/unit/project_loader/test_errors.py b/tests/legacy/unit/project_loader/test_errors.py similarity index 94% rename from tests/unit/project_loader/test_errors.py rename to tests/legacy/unit/project_loader/test_errors.py index b3ee3a5daa..4c5bcbb5b4 100644 --- a/tests/unit/project_loader/test_errors.py +++ b/tests/legacy/unit/project_loader/test_errors.py @@ -16,7 +16,7 @@ import pathlib -from snapcraft.internal.project_loader import errors +from snapcraft_legacy.internal.project_loader import errors def test_SnapcraftProjectUnusedKeyAssetError(): diff --git a/tests/unit/project_loader/test_parts.py b/tests/legacy/unit/project_loader/test_parts.py similarity index 97% rename from tests/unit/project_loader/test_parts.py rename to tests/legacy/unit/project_loader/test_parts.py index 4c4bdac3a1..e2e9aaf06f 100644 --- a/tests/unit/project_loader/test_parts.py +++ b/tests/legacy/unit/project_loader/test_parts.py @@ -19,9 +19,9 @@ from testtools.matchers import Equals -from snapcraft.internal import project_loader -from snapcraft.project import Project -from tests import fixture_setup +from snapcraft_legacy.internal import project_loader +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup from . import LoadPartBaseTest, ProjectLoaderBaseTest diff --git a/tests/unit/project_loader/test_replace_attr.py b/tests/legacy/unit/project_loader/test_replace_attr.py similarity index 98% rename from tests/unit/project_loader/test_replace_attr.py rename to tests/legacy/unit/project_loader/test_replace_attr.py index a6c81e8796..e3af832387 100644 --- a/tests/unit/project_loader/test_replace_attr.py +++ b/tests/legacy/unit/project_loader/test_replace_attr.py @@ -16,8 +16,8 @@ from testtools.matchers import Equals -from snapcraft.internal import project_loader -from tests import unit +from snapcraft_legacy.internal import project_loader +from tests.legacy import unit class VariableReplacementsTest(unit.TestCase): diff --git a/tests/unit/project_loader/test_schema.py b/tests/legacy/unit/project_loader/test_schema.py similarity index 96% rename from tests/unit/project_loader/test_schema.py rename to tests/legacy/unit/project_loader/test_schema.py index ff283bd1e5..f16a952c63 100644 --- a/tests/unit/project_loader/test_schema.py +++ b/tests/legacy/unit/project_loader/test_schema.py @@ -23,11 +23,11 @@ from testscenarios.scenarios import multiply_scenarios from testtools.matchers import Contains, Equals -import snapcraft.yaml_utils.errors -from snapcraft import project -from snapcraft.internal.errors import PluginError -from snapcraft.internal.project_loader import errors, load_config -from tests import fixture_setup +import snapcraft_legacy.yaml_utils.errors +from snapcraft_legacy import project +from snapcraft_legacy.internal.errors import PluginError +from snapcraft_legacy.internal.project_loader import errors, load_config +from tests.legacy import fixture_setup from . import ProjectLoaderBaseTest @@ -280,13 +280,13 @@ def test_duplicate_aliases(self): def test_invalid_alias(self): apps = [("test", dict(command="test", aliases=[".test"]))] raised = self.assertRaises( - snapcraft.yaml_utils.errors.YamlValidationError, + snapcraft_legacy.yaml_utils.errors.YamlValidationError, self.make_snapcraft_project, apps, ) expected = ( "The {path!r} property does not match the required schema: " - "{alias!r} does not match ".format( + "{alias!r} is not a valid alias.".format( path="apps/test/aliases[0]", alias=".test" ) ) diff --git a/tests/unit/remote_build/__init__.py b/tests/legacy/unit/remote_build/__init__.py similarity index 100% rename from tests/unit/remote_build/__init__.py rename to tests/legacy/unit/remote_build/__init__.py diff --git a/tests/unit/remote_build/test_errors.py b/tests/legacy/unit/remote_build/test_errors.py similarity index 97% rename from tests/unit/remote_build/test_errors.py rename to tests/legacy/unit/remote_build/test_errors.py index c4e47f7884..05865a6339 100644 --- a/tests/unit/remote_build/test_errors.py +++ b/tests/legacy/unit/remote_build/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.remote_build import errors +from snapcraft_legacy.internal.remote_build import errors class TestSnapcraftException: diff --git a/tests/unit/remote_build/test_info_file.py b/tests/legacy/unit/remote_build/test_info_file.py similarity index 94% rename from tests/unit/remote_build/test_info_file.py rename to tests/legacy/unit/remote_build/test_info_file.py index 924b474d47..d442955e8d 100644 --- a/tests/unit/remote_build/test_info_file.py +++ b/tests/legacy/unit/remote_build/test_info_file.py @@ -19,8 +19,8 @@ import fixtures from testtools.matchers import Equals, FileExists -from snapcraft.internal.remote_build import InfoFile -from tests import unit +from snapcraft_legacy.internal.remote_build import InfoFile +from tests.legacy import unit class TestInfoFile(unit.TestCase): diff --git a/tests/unit/remote_build/test_launchpad.py b/tests/legacy/unit/remote_build/test_launchpad.py similarity index 94% rename from tests/unit/remote_build/test_launchpad.py rename to tests/legacy/unit/remote_build/test_launchpad.py index 803d52b4ec..626c85dcba 100644 --- a/tests/unit/remote_build/test_launchpad.py +++ b/tests/legacy/unit/remote_build/test_launchpad.py @@ -20,11 +20,11 @@ import fixtures from testtools.matchers import Contains, Equals -import snapcraft -from snapcraft.internal.remote_build import LaunchpadClient, errors -from snapcraft.internal.sources._git import Git -from snapcraft.internal.sources.errors import SnapcraftPullError -from tests import unit +import snapcraft_legacy +from snapcraft_legacy.internal.remote_build import LaunchpadClient, errors +from snapcraft_legacy.internal.sources._git import Git +from snapcraft_legacy.internal.sources.errors import SnapcraftPullError +from tests.legacy import unit from . import TestDir @@ -217,7 +217,7 @@ def setUp(self): def test_login(self): self.assertThat(self.lpc.user, Equals("user")) self.fake_login_with.mock.assert_called_with( - "snapcraft remote-build {}".format(snapcraft.__version__), + "snapcraft remote-build {}".format(snapcraft_legacy.__version__), "production", mock.ANY, credentials_file=mock.ANY, @@ -289,7 +289,7 @@ def test_start_build(self): self.lpc.start_build() @mock.patch( - "tests.unit.remote_build.test_launchpad.SnapImpl.requestBuilds", + "tests.legacy.unit.remote_build.test_launchpad.SnapImpl.requestBuilds", return_value=SnapBuildReqImpl( status="Failed", error_message="snapcraft.yaml not found..." ), @@ -299,7 +299,7 @@ def test_start_build_error(self, mock_rb): self.assertThat(str(raised), Contains("snapcraft.yaml not found...")) @mock.patch( - "tests.unit.remote_build.test_launchpad.SnapImpl.requestBuilds", + "tests.legacy.unit.remote_build.test_launchpad.SnapImpl.requestBuilds", return_value=SnapBuildReqImpl(status="Pending", error_message=""), ) @mock.patch("time.time", return_value=500) @@ -327,7 +327,7 @@ def test_issue_build_request_defaults(self): ), ) - @mock.patch("snapcraft.internal.remote_build.LaunchpadClient._download_file") + @mock.patch("snapcraft_legacy.internal.remote_build.LaunchpadClient._download_file") def test_monitor_build(self, mock_download_file): open("test_i386.txt", "w").close() open("test_i386.1.txt", "w").close() @@ -361,9 +361,10 @@ def test_monitor_build(self, mock_download_file): ), ) - @mock.patch("snapcraft.internal.remote_build.LaunchpadClient._download_file") + @mock.patch("snapcraft_legacy.internal.remote_build.LaunchpadClient._download_file") @mock.patch( - "tests.unit.remote_build.test_launchpad.BuildImpl.getFileUrls", return_value=[] + "tests.legacy.unit.remote_build.test_launchpad.BuildImpl.getFileUrls", + return_value=[], ) @mock.patch("logging.Logger.error") def test_monitor_build_error(self, mock_log, mock_urls, mock_download_file): @@ -389,7 +390,7 @@ def test_monitor_build_error(self, mock_log, mock_urls, mock_download_file): ), ) - @mock.patch("snapcraft.internal.remote_build.LaunchpadClient._download_file") + @mock.patch("snapcraft_legacy.internal.remote_build.LaunchpadClient._download_file") @mock.patch("time.time", return_value=500) def test_monitor_build_error_timeout(self, mock_time, mock_rb): self.lpc.deadline = 499 @@ -428,7 +429,7 @@ def _make_snapcraft_project(self): """ ) snapcraft_yaml_file_path = self.make_snapcraft_yaml(yaml) - project = snapcraft.project.Project( + project = snapcraft_legacy.project.Project( snapcraft_yaml_file_path=snapcraft_yaml_file_path ) return project diff --git a/tests/unit/remote_build/test_worktree.py b/tests/legacy/unit/remote_build/test_worktree.py similarity index 97% rename from tests/unit/remote_build/test_worktree.py rename to tests/legacy/unit/remote_build/test_worktree.py index 1614c9cd3b..e5de547195 100644 --- a/tests/unit/remote_build/test_worktree.py +++ b/tests/legacy/unit/remote_build/test_worktree.py @@ -21,10 +21,10 @@ from testtools.matchers import Equals -from snapcraft import yaml_utils -from snapcraft.internal.remote_build import WorkTree -from snapcraft.project import Project -from tests import fixture_setup, unit +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.internal.remote_build import WorkTree +from snapcraft_legacy.project import Project +from tests.legacy import fixture_setup, unit from . import TestDir diff --git a/tests/legacy/unit/repo/__init__.py b/tests/legacy/unit/repo/__init__.py new file mode 100644 index 0000000000..2777051ccb --- /dev/null +++ b/tests/legacy/unit/repo/__init__.py @@ -0,0 +1,32 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2017-2018 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import logging +import tempfile + +import fixtures + +from tests.legacy import unit + + +class RepoBaseTestCase(unit.TestCase): + def setUp(self): + super().setUp() + fake_logger = fixtures.FakeLogger(level=logging.ERROR) + self.useFixture(fake_logger) + tempdirObj = tempfile.TemporaryDirectory() + self.addCleanup(tempdirObj.cleanup) + self.tempdir = tempdirObj.name diff --git a/tests/unit/repo/test_apt_cache.py b/tests/legacy/unit/repo/test_apt_cache.py similarity index 94% rename from tests/unit/repo/test_apt_cache.py rename to tests/legacy/unit/repo/test_apt_cache.py index 3025f82e97..676154fa02 100644 --- a/tests/unit/repo/test_apt_cache.py +++ b/tests/legacy/unit/repo/test_apt_cache.py @@ -22,8 +22,8 @@ import fixtures from testtools.matchers import Equals -from snapcraft.internal.repo.apt_cache import AptCache -from tests import unit +from snapcraft_legacy.internal.repo.apt_cache import AptCache +from tests.legacy import unit class TestAptStageCache(unit.TestCase): @@ -67,7 +67,7 @@ def test_stage_cache(self): stage_cache = Path(self.path, "cache") stage_cache.mkdir(exist_ok=True, parents=True) self.fake_apt = self.useFixture( - fixtures.MockPatch("snapcraft.internal.repo.apt_cache.apt") + fixtures.MockPatch("snapcraft_legacy.internal.repo.apt_cache.apt") ).mock with AptCache(stage_cache=stage_cache): @@ -98,7 +98,7 @@ def test_stage_cache(self): def test_stage_cache_in_snap(self): self.fake_apt = self.useFixture( - fixtures.MockPatch("snapcraft.internal.repo.apt_cache.apt") + fixtures.MockPatch("snapcraft_legacy.internal.repo.apt_cache.apt") ).mock stage_cache = Path(self.path, "cache") @@ -108,7 +108,9 @@ def test_stage_cache_in_snap(self): snap.mkdir(exist_ok=True, parents=True) self.useFixture( - fixtures.MockPatch("snapcraft.internal.common.is_snap", return_value=True) + fixtures.MockPatch( + "snapcraft_legacy.internal.common.is_snap", return_value=True + ) ) self.useFixture(fixtures.EnvironmentVariable("SNAP", str(snap))) @@ -155,7 +157,7 @@ def test_stage_cache_in_snap(self): def test_host_cache_setup(self): self.fake_apt = self.useFixture( - fixtures.MockPatch("snapcraft.internal.repo.apt_cache.apt") + fixtures.MockPatch("snapcraft_legacy.internal.repo.apt_cache.apt") ).mock with AptCache() as _: diff --git a/tests/legacy/unit/repo/test_apt_key_manager.py b/tests/legacy/unit/repo/test_apt_key_manager.py new file mode 100644 index 0000000000..dd25c2d8ea --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_key_manager.py @@ -0,0 +1,316 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import subprocess +from unittest import mock +from unittest.mock import call + +import gnupg +import pytest + +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPpa, +) +from snapcraft_legacy.internal.repo import apt_ppa, errors +from snapcraft_legacy.internal.repo.apt_key_manager import AptKeyManager + + +@pytest.fixture(autouse=True) +def mock_environ_copy(): + with mock.patch("os.environ.copy") as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_gnupg(tmp_path, autouse=True): + with mock.patch("gnupg.GPG", spec=gnupg.GPG) as m: + m.return_value.import_keys.return_value.fingerprints = [ + "FAKE-KEY-ID-FROM-GNUPG" + ] + yield m + + +@pytest.fixture(autouse=True) +def mock_run(): + with mock.patch("subprocess.run", spec=subprocess.run) as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_apt_ppa_get_signing_key(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.get_launchpad_ppa_key_id", + spec=apt_ppa.get_launchpad_ppa_key_id, + return_value="FAKE-PPA-SIGNING-KEY", + ) as m: + yield m + + +@pytest.fixture +def key_assets(tmp_path): + key_assets = tmp_path / "key-assets" + key_assets.mkdir(parents=True) + yield key_assets + + +@pytest.fixture +def gpg_keyring(tmp_path): + yield tmp_path / "keyring.gpg" + + +@pytest.fixture +def apt_gpg(key_assets, gpg_keyring): + yield AptKeyManager( + gpg_keyring=gpg_keyring, key_assets=key_assets, + ) + + +def test_find_asset( + apt_gpg, key_assets, +): + key_id = "8" * 40 + expected_key_path = key_assets / ("8" * 8 + ".asc") + expected_key_path.write_text("key") + + key_path = apt_gpg.find_asset_with_key_id(key_id=key_id) + + assert key_path == expected_key_path + + +def test_find_asset_none(apt_gpg,): + key_path = apt_gpg.find_asset_with_key_id(key_id="foo") + + assert key_path is None + + +def test_get_key_fingerprints( + apt_gpg, mock_gnupg, +): + with mock.patch("tempfile.NamedTemporaryFile") as m: + m.return_value.__enter__.return_value.name = "/tmp/foo" + ids = apt_gpg.get_key_fingerprints(key="8" * 40) + + assert ids == ["FAKE-KEY-ID-FROM-GNUPG"] + assert mock_gnupg.mock_calls == [ + call(keyring="/tmp/foo"), + call().import_keys(key_data="8888888888888888888888888888888888888888"), + ] + + +@pytest.mark.parametrize( + "stdout,expected", + [ + (b"nothing exported", False), + (b"BEGIN PGP PUBLIC KEY BLOCK", True), + (b"invalid", False), + ], +) +def test_is_key_installed( + stdout, expected, apt_gpg, mock_run, +): + mock_run.return_value.stdout = stdout + + is_installed = apt_gpg.is_key_installed(key_id="foo") + + assert is_installed is expected + assert mock_run.mock_calls == [ + call( + ["sudo", "apt-key", "export", "foo"], + check=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_is_key_installed_with_apt_key_failure( + apt_gpg, mock_run, +): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["apt-key"], returncode=1, output=b"some error" + ) + + is_installed = apt_gpg.is_key_installed(key_id="foo") + + assert is_installed is False + + +def test_install_key( + apt_gpg, gpg_keyring, mock_run, +): + key = "some-fake-key" + apt_gpg.install_key(key=key) + + assert mock_run.mock_calls == [ + call( + ["sudo", "apt-key", "--keyring", str(gpg_keyring), "add", "-"], + check=True, + env={"LANG": "C.UTF-8"}, + input=b"some-fake-key", + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_install_key_with_apt_key_failure(apt_gpg, mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["foo"], returncode=1, output=b"some error" + ) + + with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + apt_gpg.install_key(key="FAKEKEY") + + assert exc_info.value._output == "some error" + assert exc_info.value._key == "FAKEKEY" + + +def test_install_key_from_keyserver(apt_gpg, gpg_keyring, mock_run): + apt_gpg.install_key_from_keyserver(key_id="FAKE_KEYID", key_server="key.server") + + assert mock_run.mock_calls == [ + call( + [ + "sudo", + "apt-key", + "--keyring", + str(gpg_keyring), + "adv", + "--keyserver", + "key.server", + "--recv-keys", + "FAKE_KEYID", + ], + check=True, + env={"LANG": "C.UTF-8"}, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + ] + + +def test_install_key_from_keyserver_with_apt_key_failure( + apt_gpg, gpg_keyring, mock_run +): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["apt-key"], returncode=1, output=b"some error" + ) + + with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + apt_gpg.install_key_from_keyserver( + key_id="fake-key-id", key_server="fake-server" + ) + + assert exc_info.value._output == "some error" + assert exc_info.value._key_id == "fake-key-id" + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed" +) +@pytest.mark.parametrize( + "is_installed", [True, False], +) +def test_install_package_repository_key_already_installed( + mock_is_key_installed, is_installed, apt_gpg, +): + mock_is_key_installed.return_value = is_installed + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id="8" * 40, + key_server="xkeyserver.com", + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is not is_installed + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch("snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key") +def test_install_package_repository_key_from_asset( + mock_install_key, mock_is_key_installed, apt_gpg, key_assets, +): + key_id = "123456789012345678901234567890123456AABB" + expected_key_path = key_assets / "3456AABB.asc" + expected_key_path.write_text("key-data") + + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id=key_id, + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key.mock_calls == [call(key="key-data")] + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" +) +def test_install_package_repository_key_apt_from_keyserver( + mock_install_key_from_keyserver, mock_is_key_installed, apt_gpg, +): + key_id = "8" * 40 + + package_repo = PackageRepositoryApt( + components=["main", "multiverse"], + key_id=key_id, + key_server="key.server", + suites=["xenial"], + url="http://archive.ubuntu.com/ubuntu", + ) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key_from_keyserver.mock_calls == [ + call(key_id=key_id, key_server="key.server") + ] + + +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, +) +@mock.patch( + "snapcraft_legacy.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" +) +def test_install_package_repository_key_ppa_from_keyserver( + mock_install_key_from_keyserver, mock_is_key_installed, apt_gpg, +): + package_repo = PackageRepositoryAptPpa(ppa="test/ppa",) + + updated = apt_gpg.install_package_repository_key(package_repo=package_repo) + + assert updated is True + assert mock_install_key_from_keyserver.mock_calls == [ + call(key_id="FAKE-PPA-SIGNING-KEY", key_server="keyserver.ubuntu.com") + ] diff --git a/tests/legacy/unit/repo/test_apt_ppa.py b/tests/legacy/unit/repo/test_apt_ppa.py new file mode 100644 index 0000000000..033ef83ed7 --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_ppa.py @@ -0,0 +1,60 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from unittest import mock +from unittest.mock import call + +import launchpadlib +import pytest + +from snapcraft_legacy.internal.repo import apt_ppa, errors + + +@pytest.fixture +def mock_launchpad(autouse=True): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.Launchpad", + spec=launchpadlib.launchpad.Launchpad, + ) as m: + m.login_anonymously.return_value.load.return_value.signing_key_fingerprint = ( + "FAKE-PPA-SIGNING-KEY" + ) + yield m + + +def test_split_ppa_parts(): + owner, name = apt_ppa.split_ppa_parts(ppa="test-owner/test-name") + + assert owner == "test-owner" + assert name == "test-name" + + +def test_split_ppa_parts_invalid(): + with pytest.raises(errors.AptPPAInstallError) as exc_info: + apt_ppa.split_ppa_parts(ppa="ppa-missing-slash") + + assert exc_info.value._ppa == "ppa-missing-slash" + + +def test_get_launchpad_ppa_key_id(mock_launchpad,): + key_id = apt_ppa.get_launchpad_ppa_key_id(ppa="ppa-owner/ppa-name") + + assert key_id == "FAKE-PPA-SIGNING-KEY" + assert mock_launchpad.mock_calls == [ + call.login_anonymously("snapcraft", "production"), + call.login_anonymously().load("~ppa-owner/+archive/ppa-name"), + ] diff --git a/tests/legacy/unit/repo/test_apt_sources_manager.py b/tests/legacy/unit/repo/test_apt_sources_manager.py new file mode 100644 index 0000000000..a955553fe1 --- /dev/null +++ b/tests/legacy/unit/repo/test_apt_sources_manager.py @@ -0,0 +1,260 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2021 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pathlib +import subprocess +from textwrap import dedent +from unittest import mock +from unittest.mock import call + +import pytest + +from snapcraft_legacy.internal.meta.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPpa, +) +from snapcraft_legacy.internal.repo import apt_ppa, apt_sources_manager, errors + + +@pytest.fixture(autouse=True) +def mock_apt_ppa_get_signing_key(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_ppa.get_launchpad_ppa_key_id", + spec=apt_ppa.get_launchpad_ppa_key_id, + return_value="FAKE-PPA-SIGNING-KEY", + ) as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_environ_copy(): + with mock.patch("os.environ.copy") as m: + yield m + + +@pytest.fixture(autouse=True) +def mock_host_arch(): + with mock.patch( + "snapcraft_legacy.internal.repo.apt_sources_manager.ProjectOptions" + ) as m: + m.return_value.deb_arch = "FAKE-HOST-ARCH" + yield m + + +@pytest.fixture(autouse=True) +def mock_run(): + with mock.patch("subprocess.run") as m: + yield m + + +@pytest.fixture() +def mock_sudo_write(): + def write_file(*, dst_path: pathlib.Path, content: bytes) -> None: + dst_path.write_bytes(content) + + with mock.patch( + "snapcraft_legacy.internal.repo.apt_sources_manager._sudo_write_file" + ) as m: + m.side_effect = write_file + yield m + + +@pytest.fixture(autouse=True) +def mock_version_codename(): + with mock.patch( + "snapcraft_legacy.internal.os_release.OsRelease.version_codename", + return_value="FAKE-CODENAME", + ) as m: + yield m + + +@pytest.fixture +def apt_sources_mgr(tmp_path): + sources_list_d = tmp_path / "sources.list.d" + sources_list_d.mkdir(parents=True) + + yield apt_sources_manager.AptSourcesManager(sources_list_d=sources_list_d,) + + +@mock.patch("tempfile.NamedTemporaryFile") +@mock.patch("os.unlink") +def test_sudo_write_file(mock_unlink, mock_tempfile, mock_run, tmp_path): + mock_tempfile.return_value.__enter__.return_value.name = "/tmp/foobar" + + apt_sources_manager._sudo_write_file(dst_path="/foo/bar", content=b"some-content") + + assert mock_tempfile.mock_calls == [ + call(delete=False), + call().__enter__(), + call().__enter__().write(b"some-content"), + call().__enter__().flush(), + call().__exit__(None, None, None), + ] + assert mock_run.mock_calls == [ + call( + [ + "sudo", + "install", + "--owner=root", + "--group=root", + "--mode=0644", + "/tmp/foobar", + "/foo/bar", + ], + check=True, + ) + ] + assert mock_unlink.mock_calls == [call("/tmp/foobar")] + + +def test_sudo_write_file_fails(mock_run): + mock_run.side_effect = subprocess.CalledProcessError( + cmd=["sudo"], returncode=1, output=b"some error" + ) + + with pytest.raises(RuntimeError) as error: + apt_sources_manager._sudo_write_file( + dst_path="/foo/bar", content=b"some-content" + ) + + assert ( + str(error.value).startswith( + "Failed to install repository config with: ['sudo', 'install'" + ) + is True + ) + + +@pytest.mark.parametrize( + "package_repo,name,content", + [ + ( + PackageRepositoryApt( + architectures=["amd64", "arm64"], + components=["test-component"], + formats=["deb", "deb-src"], + key_id="A" * 40, + suites=["test-suite1", "test-suite2"], + url="http://test.url/ubuntu", + ), + "snapcraft-http_test_url_ubuntu.sources", + dedent( + """\ + Types: deb deb-src + URIs: http://test.url/ubuntu + Suites: test-suite1 test-suite2 + Components: test-component + Architectures: amd64 arm64 + """ + ).encode(), + ), + ( + PackageRepositoryApt( + architectures=["amd64", "arm64"], + components=["test-component"], + key_id="A" * 40, + name="NO-FORMAT", + suites=["test-suite1", "test-suite2"], + url="http://test.url/ubuntu", + ), + "snapcraft-NO-FORMAT.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: test-suite1 test-suite2 + Components: test-component + Architectures: amd64 arm64 + """ + ).encode(), + ), + ( + PackageRepositoryApt( + key_id="A" * 40, + name="WITH-PATH", + path="some-path", + url="http://test.url/ubuntu", + ), + "snapcraft-WITH-PATH.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: some-path/ + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ( + PackageRepositoryApt( + key_id="A" * 40, name="IMPLIED-PATH", url="http://test.url/ubuntu", + ), + "snapcraft-IMPLIED-PATH.sources", + dedent( + """\ + Types: deb + URIs: http://test.url/ubuntu + Suites: / + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ( + PackageRepositoryAptPpa(ppa="test/ppa"), + "snapcraft-ppa-test_ppa.sources", + dedent( + """\ + Types: deb + URIs: http://ppa.launchpad.net/test/ppa/ubuntu + Suites: FAKE-CODENAME + Components: main + Architectures: FAKE-HOST-ARCH + """ + ).encode(), + ), + ], +) +def test_install(package_repo, name, content, apt_sources_mgr, mock_sudo_write): + sources_path = apt_sources_mgr._sources_list_d / name + + changed = apt_sources_mgr.install_package_repository_sources( + package_repo=package_repo + ) + + assert changed is True + assert sources_path.read_bytes() == content + assert mock_sudo_write.mock_calls == [call(content=content, dst_path=sources_path,)] + + # Verify a second-run does not incur any changes. + mock_sudo_write.reset_mock() + + changed = apt_sources_mgr.install_package_repository_sources( + package_repo=package_repo + ) + + assert changed is False + assert sources_path.read_bytes() == content + assert mock_sudo_write.mock_calls == [] + + +def test_install_ppa_invalid(apt_sources_mgr): + repo = PackageRepositoryAptPpa(ppa="ppa-missing-slash") + + with pytest.raises(errors.AptPPAInstallError) as exc_info: + apt_sources_mgr.install_package_repository_sources(package_repo=repo) + + assert exc_info.value._ppa == "ppa-missing-slash" diff --git a/tests/unit/repo/test_base.py b/tests/legacy/unit/repo/test_base.py similarity index 98% rename from tests/unit/repo/test_base.py rename to tests/legacy/unit/repo/test_base.py index a922af9104..3aaa820990 100644 --- a/tests/unit/repo/test_base.py +++ b/tests/legacy/unit/repo/test_base.py @@ -20,8 +20,8 @@ from testtools.matchers import Equals, FileContains, FileExists, Not -from snapcraft.internal.repo._base import BaseRepo, get_pkg_name_parts -from tests import unit +from snapcraft_legacy.internal.repo._base import BaseRepo, get_pkg_name_parts +from tests.legacy import unit from . import RepoBaseTestCase diff --git a/tests/unit/repo/test_deb.py b/tests/legacy/unit/repo/test_deb.py similarity index 96% rename from tests/unit/repo/test_deb.py rename to tests/legacy/unit/repo/test_deb.py index c3b98b138c..cb64f6b0f3 100644 --- a/tests/unit/repo/test_deb.py +++ b/tests/legacy/unit/repo/test_deb.py @@ -26,10 +26,10 @@ import testtools from testtools.matchers import Equals -from snapcraft.internal import repo -from snapcraft.internal.repo import errors -from snapcraft.internal.repo.deb_package import DebPackage -from tests import unit +from snapcraft_legacy.internal import repo +from snapcraft_legacy.internal.repo import errors +from snapcraft_legacy.internal.repo.deb_package import DebPackage +from tests.legacy import unit @pytest.fixture(autouse=True) @@ -43,7 +43,7 @@ def setUp(self): super().setUp() self.fake_apt_cache = self.useFixture( - fixtures.MockPatch("snapcraft.internal.repo._deb.AptCache") + fixtures.MockPatch("snapcraft_legacy.internal.repo._deb.AptCache") ).mock self.fake_run = self.useFixture( @@ -65,7 +65,7 @@ def fake_tempdir(*, suffix: str, **kwargs): self.fake_tmp_mock = self.useFixture( fixtures.MockPatch( - "snapcraft.internal.repo._deb.tempfile.TemporaryDirectory", + "snapcraft_legacy.internal.repo._deb.tempfile.TemporaryDirectory", new=fake_tempdir, ) ).mock @@ -73,7 +73,7 @@ def fake_tempdir(*, suffix: str, **kwargs): self.stage_packages_path = Path(self.path) @mock.patch( - "snapcraft.internal.repo._deb._DEFAULT_FILTERED_STAGE_PACKAGES", + "snapcraft_legacy.internal.repo._deb._DEFAULT_FILTERED_STAGE_PACKAGES", {"filtered-pkg-1", "filtered-pkg-2"}, ) def test_fetch_stage_packages(self): @@ -105,7 +105,7 @@ def test_fetch_stage_packages(self): self.assertThat(fetched_packages, Equals(["fake-package=1.0"])) @mock.patch( - "snapcraft.internal.repo._deb._DEFAULT_FILTERED_STAGE_PACKAGES", + "snapcraft_legacy.internal.repo._deb._DEFAULT_FILTERED_STAGE_PACKAGES", {"filtered-pkg-1", "filtered-pkg-2", "filtered-pkg-3:amd64", "filtered-pkg-4"}, ) def test_fetch_stage_package_filtered_arch_version(self): @@ -214,7 +214,7 @@ def setUp(self): super().setUp() self.fake_apt_cache = self.useFixture( - fixtures.MockPatch("snapcraft.internal.repo._deb.AptCache") + fixtures.MockPatch("snapcraft_legacy.internal.repo._deb.AptCache") ).mock self.fake_run = self.useFixture( @@ -230,7 +230,7 @@ def get_installed_version(package_name, resolve_virtual_packages=False): self.fake_is_dumb_terminal = self.useFixture( fixtures.MockPatch( - "snapcraft.repo._deb.is_dumb_terminal", return_value=True + "snapcraft_legacy.repo._deb.is_dumb_terminal", return_value=True ) ).mock @@ -467,7 +467,7 @@ def test_broken_package_apt_install(self): ] self.useFixture( fixtures.MockPatch( - "snapcraft.internal.repo._deb.Ubuntu.refresh_build_packages" + "snapcraft_legacy.internal.repo._deb.Ubuntu.refresh_build_packages" ) ) @@ -516,15 +516,13 @@ def fake_dpkg_query(*args, **kwargs): ).encode() elif "symlink" in args[0][2].as_posix(): raise CalledProcessError( - 1, - f"dpkg-query: no path found matching pattern {args[0][2]}", + 1, f"dpkg-query: no path found matching pattern {args[0][2]}", ) elif "target" in args[0][2].as_posix(): return "coreutils: /usr/bin/dirname\n".encode() else: raise CalledProcessError( - 1, - f"dpkg-query: no path found matching pattern {args[0][2]}", + 1, f"dpkg-query: no path found matching pattern {args[0][2]}", ) self.useFixture( diff --git a/tests/unit/repo/test_deb_package.py b/tests/legacy/unit/repo/test_deb_package.py similarity index 95% rename from tests/unit/repo/test_deb_package.py rename to tests/legacy/unit/repo/test_deb_package.py index 097f45d838..3b9b9058ac 100644 --- a/tests/unit/repo/test_deb_package.py +++ b/tests/legacy/unit/repo/test_deb_package.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.repo.deb_package import DebPackage +from snapcraft_legacy.internal.repo.deb_package import DebPackage def test_basic(): diff --git a/tests/unit/repo/test_errors.py b/tests/legacy/unit/repo/test_errors.py similarity index 99% rename from tests/unit/repo/test_errors.py rename to tests/legacy/unit/repo/test_errors.py index b41bbfe243..dc336d66eb 100644 --- a/tests/unit/repo/test_errors.py +++ b/tests/legacy/unit/repo/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.repo import errors +from snapcraft_legacy.internal.repo import errors class TestErrorFormatting: diff --git a/tests/unit/repo/test_snaps.py b/tests/legacy/unit/repo/test_snaps.py similarity index 98% rename from tests/unit/repo/test_snaps.py rename to tests/legacy/unit/repo/test_snaps.py index cfdac057f0..46b2ab1c80 100644 --- a/tests/unit/repo/test_snaps.py +++ b/tests/legacy/unit/repo/test_snaps.py @@ -19,8 +19,8 @@ import fixtures from testtools.matchers import Equals, FileContains, FileExists, Is -from snapcraft.internal.repo import errors, snaps -from tests import unit +from snapcraft_legacy.internal.repo import errors, snaps +from tests.legacy import unit class SnapPackageCurrentChannelTest(unit.TestCase): @@ -311,7 +311,8 @@ def test_install_branch(self): def test_download_from_host(self): fake_get_assertion = fixtures.MockPatch( - "snapcraft.internal.repo.snaps.get_assertion", return_value=b"foo-assert" + "snapcraft_legacy.internal.repo.snaps.get_assertion", + return_value=b"foo-assert", ) self.useFixture(fake_get_assertion) @@ -350,7 +351,8 @@ def test_download_from_host(self): def test_download_from_host_dangerous(self): fake_get_assertion = fixtures.MockPatch( - "snapcraft.internal.repo.snaps.get_assertion", return_value=b"foo-assert" + "snapcraft_legacy.internal.repo.snaps.get_assertion", + return_value=b"foo-assert", ) self.useFixture(fake_get_assertion) self.fake_snapd.snaps_result = [ @@ -658,7 +660,7 @@ class SnapdNotInstalledTestCase(unit.TestCase): def setUp(self): super().setUp() socket_path_patcher = mock.patch( - "snapcraft.internal.repo.snaps.get_snapd_socket_path_template" + "snapcraft_legacy.internal.repo.snaps.get_snapd_socket_path_template" ) mock_socket_path = socket_path_patcher.start() mock_socket_path.return_value = "http+unix://nonexisting" diff --git a/tests/unit/repo/test_ua_manager.py b/tests/legacy/unit/repo/test_ua_manager.py similarity index 97% rename from tests/unit/repo/test_ua_manager.py rename to tests/legacy/unit/repo/test_ua_manager.py index df7713323e..5bb314fa00 100644 --- a/tests/unit/repo/test_ua_manager.py +++ b/tests/legacy/unit/repo/test_ua_manager.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.internal.repo import ua_manager +from snapcraft_legacy.internal.repo import ua_manager def test_ua_manager(fake_process): diff --git a/tests/unit/states/__init__.py b/tests/legacy/unit/review_tools/__init__.py similarity index 100% rename from tests/unit/states/__init__.py rename to tests/legacy/unit/review_tools/__init__.py diff --git a/tests/unit/review_tools/test_errors.py b/tests/legacy/unit/review_tools/test_errors.py similarity index 99% rename from tests/unit/review_tools/test_errors.py rename to tests/legacy/unit/review_tools/test_errors.py index d785b85c16..c59ffb5305 100644 --- a/tests/unit/review_tools/test_errors.py +++ b/tests/legacy/unit/review_tools/test_errors.py @@ -16,7 +16,7 @@ from textwrap import dedent -from snapcraft.internal.review_tools import errors +from snapcraft_legacy.internal.review_tools import errors class TestSnapcraftException: diff --git a/tests/unit/review_tools/test_runner.py b/tests/legacy/unit/review_tools/test_runner.py similarity index 96% rename from tests/unit/review_tools/test_runner.py rename to tests/legacy/unit/review_tools/test_runner.py index aa22d0972d..67ccc14b51 100644 --- a/tests/unit/review_tools/test_runner.py +++ b/tests/legacy/unit/review_tools/test_runner.py @@ -20,8 +20,8 @@ import fixtures -from snapcraft.internal import review_tools -from tests import unit +from snapcraft_legacy.internal import review_tools +from tests.legacy import unit class RunTest(unit.TestCase): @@ -37,7 +37,7 @@ def setUp(self): self.user_common_path = pathlib.Path(self.path) / "common" self.useFixture( fixtures.MockPatch( - "snapcraft.internal.review_tools._runner._get_review_tools_user_common", + "snapcraft_legacy.internal.review_tools._runner._get_review_tools_user_common", return_value=self.user_common_path, ) ) diff --git a/tests/unit/sources/__init__.py b/tests/legacy/unit/sources/__init__.py similarity index 97% rename from tests/unit/sources/__init__.py rename to tests/legacy/unit/sources/__init__.py index 4f513b6389..674767f54e 100644 --- a/tests/unit/sources/__init__.py +++ b/tests/legacy/unit/sources/__init__.py @@ -16,7 +16,7 @@ from unittest import mock -from tests import unit +from tests.legacy import unit class SourceTestCase(unit.TestCase): diff --git a/tests/unit/sources/test_7z.py b/tests/legacy/unit/sources/test_7z.py similarity index 97% rename from tests/unit/sources/test_7z.py rename to tests/legacy/unit/sources/test_7z.py index 04cf445e8f..067e793390 100644 --- a/tests/unit/sources/test_7z.py +++ b/tests/legacy/unit/sources/test_7z.py @@ -22,8 +22,8 @@ import fixtures from testtools.matchers import Equals, MatchesRegex -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit def get_side_effect(original_call): diff --git a/tests/unit/sources/test_base.py b/tests/legacy/unit/sources/test_base.py similarity index 84% rename from tests/unit/sources/test_base.py rename to tests/legacy/unit/sources/test_base.py index b80baaae3e..bb04171d77 100644 --- a/tests/unit/sources/test_base.py +++ b/tests/legacy/unit/sources/test_base.py @@ -20,8 +20,8 @@ import requests from testtools.matchers import Contains, Equals -from snapcraft.internal.sources import _base, errors -from tests import unit +from snapcraft_legacy.internal.sources import _base, errors +from tests.legacy import unit class TestFileBase(unit.TestCase): @@ -30,7 +30,7 @@ def get_mock_file_base(self, source, dir): setattr(file_src, "provision", mock.Mock()) return file_src - @mock.patch("snapcraft.internal.sources._base.FileBase.download") + @mock.patch("snapcraft_legacy.internal.sources._base.FileBase.download") def test_pull_url(self, mock_download): mock_download.return_value = "dir" file_src = self.get_mock_file_base("http://snapcraft.io/snapcraft.yaml", "dir") @@ -62,9 +62,9 @@ def test_pull_copy_source_does_not_exist(self, mock_shutil_copy2): str(raised), Contains("Failed to pull source: 'does-not-exist.tar.gz'") ) - @mock.patch("snapcraft.internal.sources._base.requests") - @mock.patch("snapcraft.internal.sources._base.download_requests_stream") - @mock.patch("snapcraft.internal.sources._base.download_urllib_source") + @mock.patch("snapcraft_legacy.internal.sources._base.requests") + @mock.patch("snapcraft_legacy.internal.sources._base.download_requests_stream") + @mock.patch("snapcraft_legacy.internal.sources._base.download_urllib_source") def test_download_file_destination(self, dus, drs, req): file_src = self.get_mock_file_base("http://snapcraft.io/snapcraft.yaml", "dir") self.assertFalse(hasattr(file_src, "file")) @@ -78,7 +78,7 @@ def test_download_file_destination(self, dus, drs, req): ), ) - @mock.patch("snapcraft.internal.common.get_url_scheme", return_value=False) + @mock.patch("snapcraft_legacy.internal.common.get_url_scheme", return_value=False) @mock.patch("requests.get", side_effect=requests.exceptions.ConnectionError("foo")) def test_download_error(self, mock_get, mock_gus): base = self.get_mock_file_base("", "") @@ -88,8 +88,8 @@ def test_download_error(self, mock_get, mock_gus): self.assertThat(str(raised), Contains("Network request error")) - @mock.patch("snapcraft.internal.sources._base.download_requests_stream") - @mock.patch("snapcraft.internal.sources._base.requests") + @mock.patch("snapcraft_legacy.internal.sources._base.download_requests_stream") + @mock.patch("snapcraft_legacy.internal.sources._base.requests") def test_download_http(self, mock_requests, mock_download): file_src = self.get_mock_file_base("http://snapcraft.io/snapcraft.yaml", "dir") @@ -104,7 +104,7 @@ def test_download_http(self, mock_requests, mock_download): mock_request.raise_for_status.assert_called_once_with() mock_download.assert_called_once_with(mock_request, file_src.file) - @mock.patch("snapcraft.internal.sources._base.download_urllib_source") + @mock.patch("snapcraft_legacy.internal.sources._base.download_urllib_source") def test_download_ftp(self, mock_download): file_src = self.get_mock_file_base("ftp://snapcraft.io/snapcraft.yaml", "dir") @@ -112,7 +112,7 @@ def test_download_ftp(self, mock_download): mock_download.assert_called_once_with(file_src.source, file_src.file) - @mock.patch("snapcraft.internal.indicators.urlretrieve") + @mock.patch("snapcraft_legacy.internal.indicators.urlretrieve") def test_download_ftp_url_opener(self, mock_urlretrieve): file_src = self.get_mock_file_base("ftp://snapcraft.io/snapcraft.yaml", "dir") diff --git a/tests/unit/sources/test_bazaar.py b/tests/legacy/unit/sources/test_bazaar.py similarity index 97% rename from tests/unit/sources/test_bazaar.py rename to tests/legacy/unit/sources/test_bazaar.py index 0f12c746ea..afc4c43c58 100644 --- a/tests/unit/sources/test_bazaar.py +++ b/tests/legacy/unit/sources/test_bazaar.py @@ -21,8 +21,8 @@ import fixtures from testtools.matchers import Equals -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit # LP: #1733584 @@ -32,7 +32,7 @@ def setUp(self): # Mock _get_source_details() since not all tests have a # full repo checkout - patcher = mock.patch("snapcraft.sources.Bazaar._get_source_details") + patcher = mock.patch("snapcraft_legacy.sources.Bazaar._get_source_details") self.mock_get_source_details = patcher.start() self.mock_get_source_details.return_value = "" self.addCleanup(patcher.stop) diff --git a/tests/unit/sources/test_checksum.py b/tests/legacy/unit/sources/test_checksum.py similarity index 94% rename from tests/unit/sources/test_checksum.py rename to tests/legacy/unit/sources/test_checksum.py index 421e5d3516..ccf47d12e0 100644 --- a/tests/unit/sources/test_checksum.py +++ b/tests/legacy/unit/sources/test_checksum.py @@ -20,9 +20,9 @@ from testtools.matchers import Equals -from snapcraft.internal.sources import errors -from snapcraft.internal.sources._checksum import verify_checksum -from tests import unit +from snapcraft_legacy.internal.sources import errors +from snapcraft_legacy.internal.sources._checksum import verify_checksum +from tests.legacy import unit class TestChecksum(unit.TestCase): diff --git a/tests/unit/sources/test_deb.py b/tests/legacy/unit/sources/test_deb.py similarity index 97% rename from tests/unit/sources/test_deb.py rename to tests/legacy/unit/sources/test_deb.py index 5a719223d2..a55faf3db2 100644 --- a/tests/unit/sources/test_deb.py +++ b/tests/legacy/unit/sources/test_deb.py @@ -20,8 +20,8 @@ from testtools.matchers import Equals -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit class TestDeb(unit.FakeFileHTTPServerBasedTestCase): diff --git a/tests/unit/sources/test_errors.py b/tests/legacy/unit/sources/test_errors.py similarity index 98% rename from tests/unit/sources/test_errors.py rename to tests/legacy/unit/sources/test_errors.py index c087f2c720..691de2a5f4 100644 --- a/tests/unit/sources/test_errors.py +++ b/tests/legacy/unit/sources/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.sources import errors +from snapcraft_legacy.internal.sources import errors class TestErrorFormatting: diff --git a/tests/unit/sources/test_git.py b/tests/legacy/unit/sources/test_git.py similarity index 99% rename from tests/unit/sources/test_git.py rename to tests/legacy/unit/sources/test_git.py index fd238b8494..cb630b87b5 100644 --- a/tests/unit/sources/test_git.py +++ b/tests/legacy/unit/sources/test_git.py @@ -22,9 +22,9 @@ import fixtures from testtools.matchers import Equals -from snapcraft.internal import sources -from snapcraft.internal.sources import errors -from tests import unit +from snapcraft_legacy.internal import sources +from snapcraft_legacy.internal.sources import errors +from tests.legacy import unit from tests.subprocess_utils import call, call_with_output @@ -37,7 +37,7 @@ class TestGit(unit.sources.SourceTestCase): # type: ignore def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.sources.Git._get_source_details") + patcher = mock.patch("snapcraft_legacy.sources.Git._get_source_details") self.mock_get_source_details = patcher.start() self.mock_get_source_details.return_value = "" self.addCleanup(patcher.stop) diff --git a/tests/unit/sources/test_local.py b/tests/legacy/unit/sources/test_local.py similarity index 98% rename from tests/unit/sources/test_local.py rename to tests/legacy/unit/sources/test_local.py index 3a23ad7e1d..43c444bf90 100644 --- a/tests/unit/sources/test_local.py +++ b/tests/legacy/unit/sources/test_local.py @@ -21,12 +21,12 @@ from testtools.matchers import DirExists, Equals, FileContains, FileExists, Not -from snapcraft.internal import common, errors, sources -from tests import unit +from snapcraft_legacy.internal import common, errors, sources +from tests.legacy import unit class TestLocal(unit.TestCase): - @mock.patch("snapcraft.internal.sources._local.glob.glob") + @mock.patch("snapcraft_legacy.internal.sources._local.glob.glob") def test_pull_does_not_change_snapcraft_files_list(self, mock_glob): # Regression test for https://bugs.launchpad.net/snapcraft/+bug/1614913 # Verify that SNAPCRAFT_FILES was not modified by the pull when there diff --git a/tests/unit/sources/test_mercurial.py b/tests/legacy/unit/sources/test_mercurial.py similarity index 98% rename from tests/unit/sources/test_mercurial.py rename to tests/legacy/unit/sources/test_mercurial.py index 91865937d9..e4dc4f2718 100644 --- a/tests/unit/sources/test_mercurial.py +++ b/tests/legacy/unit/sources/test_mercurial.py @@ -21,15 +21,15 @@ import fixtures from testtools.matchers import Equals -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit # LP: #1733584 class TestMercurial(unit.sources.SourceTestCase): # type: ignore def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.sources.Mercurial._get_source_details") + patcher = mock.patch("snapcraft_legacy.sources.Mercurial._get_source_details") self.mock_get_source_details = patcher.start() self.mock_get_source_details.return_value = "" self.addCleanup(patcher.stop) diff --git a/tests/unit/sources/test_rpm.py b/tests/legacy/unit/sources/test_rpm.py similarity index 97% rename from tests/unit/sources/test_rpm.py rename to tests/legacy/unit/sources/test_rpm.py index d86a121682..ac47427a2a 100644 --- a/tests/unit/sources/test_rpm.py +++ b/tests/legacy/unit/sources/test_rpm.py @@ -22,8 +22,8 @@ from testtools.matchers import Equals, MatchesRegex -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit class TestRpm(unit.TestCase): diff --git a/tests/unit/sources/test_script.py b/tests/legacy/unit/sources/test_script.py similarity index 88% rename from tests/unit/sources/test_script.py rename to tests/legacy/unit/sources/test_script.py index 3486eed610..7cb01c405c 100644 --- a/tests/unit/sources/test_script.py +++ b/tests/legacy/unit/sources/test_script.py @@ -19,8 +19,8 @@ from testtools.matchers import FileExists -from snapcraft.internal.sources import Script -from tests import unit +from snapcraft_legacy.internal.sources import Script +from tests.legacy import unit class TestScript(unit.TestCase): @@ -32,7 +32,7 @@ def setUp(self): self.source.file = os.path.join("destination", "file") open(self.source.file, "w").close() - @mock.patch("snapcraft.internal.sources._script.FileBase.download") + @mock.patch("snapcraft_legacy.internal.sources._script.FileBase.download") def test_download_makes_executable(self, mock_download): self.source.file = os.path.join("destination", "file") self.source.download() diff --git a/tests/unit/sources/test_snap.py b/tests/legacy/unit/sources/test_snap.py similarity index 97% rename from tests/unit/sources/test_snap.py rename to tests/legacy/unit/sources/test_snap.py index 83f151470d..b7e7177201 100644 --- a/tests/unit/sources/test_snap.py +++ b/tests/legacy/unit/sources/test_snap.py @@ -21,8 +21,8 @@ from testtools.matchers import DirExists, Equals, FileExists, MatchesRegex -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit class TestSnap(unit.TestCase): diff --git a/tests/unit/sources/test_sources.py b/tests/legacy/unit/sources/test_sources.py similarity index 99% rename from tests/unit/sources/test_sources.py rename to tests/legacy/unit/sources/test_sources.py index 130bf31b8b..a895b7e8b3 100644 --- a/tests/unit/sources/test_sources.py +++ b/tests/legacy/unit/sources/test_sources.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.internal import sources +from snapcraft_legacy.internal import sources class TestUri: diff --git a/tests/unit/sources/test_subversion.py b/tests/legacy/unit/sources/test_subversion.py similarity index 97% rename from tests/unit/sources/test_subversion.py rename to tests/legacy/unit/sources/test_subversion.py index f183d1a12a..c62f63ade2 100644 --- a/tests/unit/sources/test_subversion.py +++ b/tests/legacy/unit/sources/test_subversion.py @@ -21,8 +21,8 @@ import fixtures from testtools.matchers import Equals -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit # LP: #1733584 @@ -30,7 +30,7 @@ class TestSubversion(unit.sources.SourceTestCase): # type: ignore def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.sources.Subversion._get_source_details") + patcher = mock.patch("snapcraft_legacy.sources.Subversion._get_source_details") self.mock_get_source_details = patcher.start() self.mock_get_source_details.return_value = "" self.addCleanup(patcher.stop) diff --git a/tests/unit/sources/test_tar.py b/tests/legacy/unit/sources/test_tar.py similarity index 96% rename from tests/unit/sources/test_tar.py rename to tests/legacy/unit/sources/test_tar.py index 31b8b93a84..67c07bdead 100644 --- a/tests/unit/sources/test_tar.py +++ b/tests/legacy/unit/sources/test_tar.py @@ -21,12 +21,12 @@ import requests from testtools.matchers import Equals -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit class TestTar(unit.FakeFileHTTPServerBasedTestCase): - @mock.patch("snapcraft.sources.Tar.provision") + @mock.patch("snapcraft_legacy.sources.Tar.provision") def test_pull_tarball_must_download_to_sourcedir(self, mock_prov): plugin_name = "test_plugin" dest_dir = os.path.join("parts", plugin_name, "src") @@ -44,7 +44,7 @@ def test_pull_tarball_must_download_to_sourcedir(self, mock_prov): with open(os.path.join(dest_dir, tar_file_name), "r") as tar_file: self.assertThat(tar_file.read(), Equals("Test fake file")) - @mock.patch("snapcraft.sources.Tar.provision") + @mock.patch("snapcraft_legacy.sources.Tar.provision") def test_pull_twice_downloads_once(self, mock_prov): """If a source checksum is defined, the cache should be tried first.""" source = "http://{}:{}/{file_name}".format( diff --git a/tests/unit/sources/test_zip.py b/tests/legacy/unit/sources/test_zip.py similarity index 96% rename from tests/unit/sources/test_zip.py rename to tests/legacy/unit/sources/test_zip.py index dd0ecf1b84..1237c83eab 100644 --- a/tests/unit/sources/test_zip.py +++ b/tests/legacy/unit/sources/test_zip.py @@ -19,8 +19,8 @@ from testtools.matchers import Equals -from snapcraft.internal import sources -from tests import unit +from snapcraft_legacy.internal import sources +from tests.legacy import unit class TestZip(unit.FakeFileHTTPServerBasedTestCase): diff --git a/tests/unit/store/__init__.py b/tests/legacy/unit/states/__init__.py similarity index 100% rename from tests/unit/store/__init__.py rename to tests/legacy/unit/states/__init__.py diff --git a/tests/unit/states/conftest.py b/tests/legacy/unit/states/conftest.py similarity index 98% rename from tests/unit/states/conftest.py rename to tests/legacy/unit/states/conftest.py index fbce25210d..09bbd3db8c 100644 --- a/tests/unit/states/conftest.py +++ b/tests/legacy/unit/states/conftest.py @@ -2,7 +2,7 @@ import pytest -from snapcraft.internal import states +from snapcraft_legacy.internal import states class Project: diff --git a/tests/unit/states/test_build.py b/tests/legacy/unit/states/test_build.py similarity index 90% rename from tests/unit/states/test_build.py rename to tests/legacy/unit/states/test_build.py index b068c7d209..837185447c 100644 --- a/tests/unit/states/test_build.py +++ b/tests/legacy/unit/states/test_build.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -import snapcraft.internal -from snapcraft import yaml_utils -from tests import unit +import snapcraft_legacy.internal +from snapcraft_legacy import yaml_utils +from tests.legacy import unit from .conftest import Project @@ -33,16 +33,16 @@ def setUp(self): self.property_names = ["foo"] self.part_properties = {"foo": "bar"} - self.state = snapcraft.internal.states.BuildState( + self.state = snapcraft_legacy.internal.states.BuildState( self.property_names, self.part_properties, self.project ) class BuildStateTestCase(BuildStateBaseTestCase): @mock.patch.object( - snapcraft.internal.states.BuildState, + snapcraft_legacy.internal.states.BuildState, "__init__", - wraps=snapcraft.internal.states.BuildState.__init__, + wraps=snapcraft_legacy.internal.states.BuildState.__init__, ) def test_yaml_conversion(self, init_spy): state_string = yaml_utils.dump(self.state) @@ -58,7 +58,7 @@ def test_yaml_conversion(self, init_spy): init_spy.assert_not_called() def test_comparison(self): - other = snapcraft.internal.states.BuildState( + other = snapcraft_legacy.internal.states.BuildState( self.property_names, self.part_properties, self.project ) diff --git a/tests/unit/states/test_global_state.py b/tests/legacy/unit/states/test_global_state.py similarity index 99% rename from tests/unit/states/test_global_state.py rename to tests/legacy/unit/states/test_global_state.py index 428909d369..d3541d490c 100644 --- a/tests/unit/states/test_global_state.py +++ b/tests/legacy/unit/states/test_global_state.py @@ -16,7 +16,7 @@ from textwrap import dedent -from snapcraft.internal.states import GlobalState +from snapcraft_legacy.internal.states import GlobalState _scenarios = [ ( diff --git a/tests/unit/states/test_prime.py b/tests/legacy/unit/states/test_prime.py similarity index 88% rename from tests/unit/states/test_prime.py rename to tests/legacy/unit/states/test_prime.py index c524c242e5..d53deee0cb 100644 --- a/tests/unit/states/test_prime.py +++ b/tests/legacy/unit/states/test_prime.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -import snapcraft.internal -from snapcraft import yaml_utils -from tests import unit +import snapcraft_legacy.internal +from snapcraft_legacy import yaml_utils +from tests.legacy import unit from .conftest import Project @@ -38,7 +38,7 @@ def setUp(self): "prime": ["qux"], } - self.state = snapcraft.internal.states.PrimeState( + self.state = snapcraft_legacy.internal.states.PrimeState( self.files, self.directories, self.dependency_paths, @@ -49,9 +49,9 @@ def setUp(self): class PrimeStateTestCase(PrimeStateBaseTestCase): @mock.patch.object( - snapcraft.internal.states.PrimeState, + snapcraft_legacy.internal.states.PrimeState, "__init__", - wraps=snapcraft.internal.states.PrimeState.__init__, + wraps=snapcraft_legacy.internal.states.PrimeState.__init__, ) def test_yaml_conversion(self, init_spy): state_string = yaml_utils.dump(self.state) @@ -67,7 +67,7 @@ def test_yaml_conversion(self, init_spy): init_spy.assert_not_called() def test_comparison(self): - other = snapcraft.internal.states.PrimeState( + other = snapcraft_legacy.internal.states.PrimeState( self.files, self.directories, self.dependency_paths, diff --git a/tests/unit/states/test_pull.py b/tests/legacy/unit/states/test_pull.py similarity index 90% rename from tests/unit/states/test_pull.py rename to tests/legacy/unit/states/test_pull.py index 42a0f34a64..568d92ade2 100644 --- a/tests/unit/states/test_pull.py +++ b/tests/legacy/unit/states/test_pull.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -import snapcraft.internal -from snapcraft import yaml_utils -from tests import unit +import snapcraft_legacy.internal +from snapcraft_legacy import yaml_utils +from tests.legacy import unit from .conftest import Project @@ -33,16 +33,16 @@ def setUp(self): self.property_names = ["foo"] self.part_properties = {"foo": "bar"} - self.state = snapcraft.internal.states.PullState( + self.state = snapcraft_legacy.internal.states.PullState( self.property_names, self.part_properties, self.project ) class PullStateTestCase(PullStateBaseTestCase): @mock.patch.object( - snapcraft.internal.states.PullState, + snapcraft_legacy.internal.states.PullState, "__init__", - wraps=snapcraft.internal.states.PullState.__init__, + wraps=snapcraft_legacy.internal.states.PullState.__init__, ) def test_yaml_conversion(self, init_spy): state_string = yaml_utils.dump(self.state) @@ -58,7 +58,7 @@ def test_yaml_conversion(self, init_spy): init_spy.assert_not_called() def test_comparison(self): - other = snapcraft.internal.states.PullState( + other = snapcraft_legacy.internal.states.PullState( self.property_names, self.part_properties, self.project ) @@ -97,8 +97,7 @@ def test_properties_of_interest(self): self.assertThat(properties["source-branch"], Equals("test-source-branch")) self.assertThat(properties["source-subdir"], Equals("test-source-subdir")) self.assertThat( - properties["source-submodules"], - Equals("test-source-submodules"), + properties["source-submodules"], Equals("test-source-submodules"), ) def test_project_options_of_interest(self): diff --git a/tests/unit/states/test_stage.py b/tests/legacy/unit/states/test_stage.py similarity index 88% rename from tests/unit/states/test_stage.py rename to tests/legacy/unit/states/test_stage.py index 483971419a..138350e3a7 100644 --- a/tests/unit/states/test_stage.py +++ b/tests/legacy/unit/states/test_stage.py @@ -18,9 +18,9 @@ from testtools.matchers import Equals -import snapcraft.internal -from snapcraft import yaml_utils -from tests import unit +import snapcraft_legacy.internal +from snapcraft_legacy import yaml_utils +from tests.legacy import unit from .conftest import Project @@ -38,16 +38,16 @@ def setUp(self): "stage": ["baz"], } - self.state = snapcraft.internal.states.StageState( + self.state = snapcraft_legacy.internal.states.StageState( self.files, self.directories, self.part_properties, self.project ) class StateStageTestCase(StageStateBaseTestCase): @mock.patch.object( - snapcraft.internal.states.StageState, + snapcraft_legacy.internal.states.StageState, "__init__", - wraps=snapcraft.internal.states.StageState.__init__, + wraps=snapcraft_legacy.internal.states.StageState.__init__, ) def test_yaml_conversion(self, init_spy): state_string = yaml_utils.dump(self.state) @@ -63,7 +63,7 @@ def test_yaml_conversion(self, init_spy): init_spy.assert_not_called() def test_comparison(self): - other = snapcraft.internal.states.StageState( + other = snapcraft_legacy.internal.states.StageState( self.files, self.directories, self.part_properties, self.project ) diff --git a/tests/unit/states/test_state.py b/tests/legacy/unit/states/test_state.py similarity index 97% rename from tests/unit/states/test_state.py rename to tests/legacy/unit/states/test_state.py index 03c73448d7..833dc549cd 100644 --- a/tests/unit/states/test_state.py +++ b/tests/legacy/unit/states/test_state.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.internal.states._state import PartState +from snapcraft_legacy.internal.states._state import PartState class _TestState(PartState): diff --git a/tests/unit/store/http_client/__init__.py b/tests/legacy/unit/store/__init__.py similarity index 100% rename from tests/unit/store/http_client/__init__.py rename to tests/legacy/unit/store/__init__.py diff --git a/tests/unit/store/http_client/test_agent.py b/tests/legacy/unit/store/test_agent.py similarity index 91% rename from tests/unit/store/http_client/test_agent.py rename to tests/legacy/unit/store/test_agent.py index 0cab921263..0150c68bc9 100644 --- a/tests/unit/store/http_client/test_agent.py +++ b/tests/legacy/unit/store/test_agent.py @@ -19,11 +19,11 @@ import fixtures from testtools.matchers import Equals -from snapcraft import ProjectOptions -from snapcraft import __version__ as snapcraft_version -from snapcraft.storeapi.http_clients import agent -from tests import unit -from tests.fixture_setup.os_release import FakeOsRelease +from snapcraft_legacy import ProjectOptions +from snapcraft_legacy import __version__ as snapcraft_version +from snapcraft_legacy.storeapi import agent +from tests.legacy import unit +from tests.legacy.fixture_setup.os_release import FakeOsRelease class UserAgentTestCase(unit.TestCase): diff --git a/tests/unit/store/test_channels.py b/tests/legacy/unit/store/test_channels.py similarity index 98% rename from tests/unit/store/test_channels.py rename to tests/legacy/unit/store/test_channels.py index 14b5182277..9b674d4323 100644 --- a/tests/unit/store/test_channels.py +++ b/tests/legacy/unit/store/test_channels.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.storeapi import channels +from snapcraft_legacy.storeapi import channels class TestChannel: diff --git a/tests/unit/store/test_errors.py b/tests/legacy/unit/store/test_errors.py similarity index 98% rename from tests/unit/store/test_errors.py rename to tests/legacy/unit/store/test_errors.py index 403dc481aa..725356f187 100644 --- a/tests/unit/store/test_errors.py +++ b/tests/legacy/unit/store/test_errors.py @@ -16,7 +16,7 @@ from textwrap import dedent -from snapcraft.storeapi import errors +from snapcraft_legacy.storeapi import errors class TestSnapcraftException: diff --git a/tests/unit/store/test_metrics.py b/tests/legacy/unit/store/test_metrics.py similarity index 99% rename from tests/unit/store/test_metrics.py rename to tests/legacy/unit/store/test_metrics.py index 508d242473..892d85eae9 100644 --- a/tests/unit/store/test_metrics.py +++ b/tests/legacy/unit/store/test_metrics.py @@ -18,7 +18,7 @@ import pytest -from snapcraft.storeapi.metrics import ( +from snapcraft_legacy.storeapi.metrics import ( MetricResults, MetricsFilter, MetricsNames, diff --git a/tests/unit/store/test_status.py b/tests/legacy/unit/store/test_status.py similarity index 98% rename from tests/unit/store/test_status.py rename to tests/legacy/unit/store/test_status.py index b5740f626e..f815ada664 100644 --- a/tests/unit/store/test_status.py +++ b/tests/legacy/unit/store/test_status.py @@ -16,8 +16,8 @@ from testtools.matchers import Equals, HasLength -from snapcraft.storeapi import channels, errors, status -from tests import unit +from snapcraft_legacy.storeapi import channels, errors, status +from tests.legacy import unit class TestSnapStatusChannelDetails: diff --git a/tests/unit/store/test_store_client.py b/tests/legacy/unit/store/test_store_client.py similarity index 56% rename from tests/unit/store/test_store_client.py rename to tests/legacy/unit/store/test_store_client.py index 006a6c8c4e..e175a5ab63 100644 --- a/tests/unit/store/test_store_client.py +++ b/tests/legacy/unit/store/test_store_client.py @@ -17,10 +17,8 @@ import json import logging import os -import pathlib import tempfile from textwrap import dedent -from unittest import mock import fixtures import pytest @@ -34,143 +32,20 @@ Not, ) -import tests -from snapcraft import storeapi -from snapcraft.storeapi import errors, http_clients, metrics -from snapcraft.storeapi.v2 import channel_map, releases, validation_sets, whoami -from tests import fixture_setup, unit +from snapcraft_legacy import storeapi +from snapcraft_legacy.storeapi import errors, metrics +from snapcraft_legacy.storeapi.v2 import releases, validation_sets, whoami +from tests.legacy import fixture_setup, unit +@pytest.mark.usefixtures("memory_keyring") class StoreTestCase(unit.TestCase): def setUp(self): super().setUp() self.fake_store = self.useFixture(fixture_setup.FakeStore()) self.client = storeapi.StoreClient() - - -class LoginTestCase(StoreTestCase): - def test_login_successful(self): - self.client.login(email="dummy email", password="test correct password") - self.assertIsNotNone(self.client.auth_client.auth) - - def test_login_successful_with_one_time_password(self): - self.client.login( - email="dummy email", - password="test correct password", - otp="test correct one-time password", - ) - self.assertIsNotNone(self.client.auth_client.auth) - - def test_login_successful_with_package_attenuation(self): - self.client.login( - email="dummy email", - password="test correct password", - packages=[{"name": "foo", "series": "16"}], - ) - self.assertIsNotNone(self.client.auth_client.auth) - - def test_login_successful_with_channel_attenuation(self): - self.client.login( - email="dummy email", password="test correct password", channels=["edge"] - ) - self.assertIsNotNone(self.client.auth_client.auth) - - def test_login_successful_fully_attenuated(self): - self.client.login( - email="dummy email", - password="test correct password", - packages=[{"name": "foo", "series": "16"}], - channels=["edge"], - save=False, - ) - # Client configuration is filled, but it's not saved on disk. - self.assertThat( - self.client.auth_client._conf._get_config_path(), Not(FileExists()) - ) - self.assertIsNotNone(self.client.auth_client.auth) - - def test_login_successful_with_expiration(self): - self.client.login( - email="dummy email", - password="test correct password", - packages=[{"name": "foo", "series": "16"}], - channels=["edge"], - expires="2017-12-22", - ) - self.assertIsNotNone(self.client.auth_client.auth) - - def test_login_with_exported_login(self): - with pathlib.Path("test-exported-login").open("w") as config_fd: - print( - "[{}]".format(self.client.auth_client._conf._get_section_name()), - file=config_fd, - ) - print( - "macaroon=MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAxNGNpZCB0ZXN0IGNhdmVhdAowMDE5dmlkIHRlc3QgdmVyaWZpYWNpb24KMDAxN2NsIGxvY2FsaG9zdDozNTM1MQowMDBmc2lnbmF0dXJlIAo", - file=config_fd, - ) - print( - "unbound_discharge=MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAwZnNpZ25hdHVyZSAK", - file=config_fd, - ) - config_fd.flush() - - with pathlib.Path("test-exported-login").open() as config_fd: - self.client.login(config_fd=config_fd) - - self.assertThat( - self.client.auth_client._conf.get("macaroon"), - Equals( - "MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAxNGNpZCB0ZXN0IGNhdmVhdAowMDE5dmlkIHRlc3QgdmVyaWZpYWNpb24KMDAxN2NsIGxvY2FsaG9zdDozNTM1MQowMDBmc2lnbmF0dXJlIAo" - ), - ) - self.assertThat( - self.client.auth_client._conf.get("unbound_discharge"), - Equals("MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAwZnNpZ25hdHVyZSAK"), - ) - self.assertThat( - self.client.auth_client.auth, - Equals( - "Macaroon root=MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAxNGNpZCB0ZXN0IGNhdmVhdAowMDE5dmlkIHRlc3QgdmVyaWZpYWNpb24KMDAxN2NsIGxvY2FsaG9zdDozNTM1MQowMDBmc2lnbmF0dXJlIAo, discharge=MDAwZWxvY2F0aW9uIAowMDEwaWRlbnRpZmllciAKMDAyZnNpZ25hdHVyZSDmRizXTOkAmfmy5hGCm7F0H4LBea16YbJYVhDkAJZ-Ago" - ), - ) - - def test_failed_login_with_wrong_password(self): - self.assertRaises( - http_clients.errors.StoreAuthenticationError, - self.client.login, - email="dummy email", - password="wrong password", - ) - - def test_failed_login_requires_one_time_password(self): - self.assertRaises( - http_clients.errors.StoreTwoFactorAuthenticationRequired, - self.client.login, - email="dummy email", - password="test requires 2fa", - ) - - def test_failed_login_with_wrong_one_time_password(self): - self.assertRaises( - http_clients.errors.StoreAuthenticationError, - self.client.login, - email="dummy email", - password="test correct password", - otp="wrong one-time password", - ) - - def test_failed_login_with_unregistered_snap(self): - raised = self.assertRaises( - errors.GeneralStoreError, - self.client.login, - email="dummy email", - password="test correct password", - packages=[{"name": "unregistered-snap-name", "series": "16"}], - ) - - self.assertThat(str(raised), Contains("not found")) + self.client.login(email="dummy", password="test correct password", ttl=2) class DownloadTestCase(StoreTestCase): @@ -179,7 +54,6 @@ class DownloadTestCase(StoreTestCase): EXPECTED_SHA3_384 = "" def test_download_nonexistent_snap_raises_exception(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.SnapNotFoundError, @@ -197,7 +71,6 @@ def test_download_nonexistent_snap_raises_exception(self): def test_download_snap(self): fake_logger = fixtures.FakeLogger(level=logging.INFO) self.useFixture(fake_logger) - self.client.login(email="dummy", password="test correct password") download_path = os.path.join(self.path, "test-snap.snap") self.client.download("test-snap", risk="stable", download_path=download_path) self.assertThat(download_path, FileExists()) @@ -205,7 +78,6 @@ def test_download_snap(self): def test_download_snap_missing_risk(self): fake_logger = fixtures.FakeLogger(level=logging.INFO) self.useFixture(fake_logger) - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.SnapNotFoundError, @@ -220,7 +92,6 @@ def test_download_snap_missing_risk(self): self.expectThat(raised._arch, Is(None)) def test_download_from_brand_store_requires_store(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.SnapNotFoundError, self.client.download, @@ -243,7 +114,6 @@ def test_download_from_branded_store(self): self.useFixture( fixtures.EnvironmentVariable("SNAPCRAFT_UBUNTU_STORE", "Test-Branded") ) - self.client.login(email="dummy", password="test correct password") download_path = os.path.join(self.path, "brand.snap") self.client.download( @@ -252,7 +122,6 @@ def test_download_from_branded_store(self): self.assertThat(download_path, FileExists()) def test_download_already_downloaded_snap(self): - self.client.login(email="dummy", password="test correct password") download_path = os.path.join(self.path, "test-snap.snap") # download first time. self.client.download("test-snap", risk="stable", download_path=download_path) @@ -266,7 +135,6 @@ def test_download_already_downloaded_snap(self): def test_download_on_sha_mismatch(self): fake_logger = fixtures.FakeLogger(level=logging.INFO) self.useFixture(fake_logger) - self.client.login(email="dummy", password="test correct password") download_path = os.path.join(self.path, "test-snap.snap") # Write a wrong file in the download path. open(download_path, "w").close() @@ -277,7 +145,6 @@ def test_download_on_sha_mismatch(self): self.assertThat(second_stat, Not(Equals(first_stat))) def test_download_with_hash_mismatch_raises_exception(self): - self.client.login(email="dummy", password="test correct password") download_path = os.path.join(self.path, "test-snap.snap") self.assertRaises( errors.SHAMismatchError, @@ -289,26 +156,7 @@ def test_download_with_hash_mismatch_raises_exception(self): class PushSnapBuildTestCase(StoreTestCase): - def test_push_snap_build_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - self.client.push_snap_build("snap-id", "dummy") - self.assertFalse(self.fake_store.needs_refresh) - - def test_push_snap_build_not_implemented(self): - # If the "enable_snap_build" feature switch is off in the store, we - # will get a descriptive error message. - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.push_snap_build, - "snap-id", - "test-not-implemented", - ) - self.assertThat(raised.error_code, Equals(501)) - def test_push_snap_build_invalid_data(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreSnapBuildError, self.client.push_snap_build, @@ -320,27 +168,13 @@ def test_push_snap_build_invalid_data(self): Equals("Could not assert build: The snap-build assertion is not " "valid."), ) - def test_push_snap_build_unexpected_data(self): - # The endpoint in SCA would never return plain/text, however anything - # might happen in the internet, so we are a little defensive. - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.push_snap_build, - "snap-id", - "test-unexpected-data", - ) - self.assertThat(raised.error_code, Equals(500)) - def test_push_snap_build_successfully(self): - self.client.login(email="dummy", password="test correct password") # No exception will be raised if this is successful. self.client.push_snap_build("snap-id", "dummy") class GetAccountInformationTestCase(StoreTestCase): def test_get_account_information_successfully(self): - self.client.login(email="dummy", password="test correct password") self.assertThat( self.client.get_account_information(), Equals( @@ -418,91 +252,9 @@ def test_get_account_information_successfully(self): ), ) - def test_get_account_information_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - self.assertThat( - self.client.get_account_information(), - Equals( - { - "account_id": "abcd", - "account_keys": [], - "snaps": { - "16": { - "basic": { - "snap-id": "snap-id", - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - "core": { - "snap-id": "good", - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - "core-no-dev": { - "snap-id": "no-dev", - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - "badrequest": { - "snap-id": "badrequest", - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - "no-revoked": { - "snap-id": "no-revoked", - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - "revoked": { - "snap-id": "revoked", - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - "test-snap-with-dev": { - "price": None, - "private": False, - "since": "2016-12-12T01:01:01Z", - "snap-id": "test-snap-id-with-dev", - "status": "Approved", - }, - "test-snap-with-no-validations": { - "price": None, - "private": False, - "since": "2016-12-12T01:01:01Z", - "snap-id": "test-snap-id-with-no-validations", - "status": "Approved", - }, - "no-id": { - "snap-id": None, - "status": "Approved", - "private": False, - "price": None, - "since": "2016-12-12T01:01:01Z", - }, - } - }, - } - ), - ) - self.assertFalse(self.fake_store.needs_refresh) - class RegisterKeyTestCase(StoreTestCase): def test_register_key_successfully(self): - self.client.login(email="dummy", password="test correct password") # No exception will be raised if this is successful. self.client.register_key( dedent( @@ -513,32 +265,7 @@ def test_register_key_successfully(self): ) ) - def test_register_key_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - self.client.register_key( - dedent( - """\ - name: default - public-key-sha3-384: abcd - """ - ) - ) - self.assertFalse(self.fake_store.needs_refresh) - - def test_not_implemented(self): - # If the enable_account_key feature switch is off in the store, we - # will get a 501 Not Implemented response. - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.register_key, - "test-not-implemented", - ) - self.assertThat(raised.error_code, Equals(501)) - def test_invalid_data(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreKeyRegistrationError, self.client.register_key, @@ -555,28 +282,18 @@ def test_invalid_data(self): class RegisterTestCase(StoreTestCase): def test_register_name_successfully(self): - self.client.login(email="dummy", password="test correct password") # No exception will be raised if this is successful self.client.register("test-good-snap-name") def test_register_name_successfully_to_store_id(self): - self.client.login(email="dummy", password="test correct password") # No exception will be raised if this is successful self.client.register("test-good-snap-name", store_id="my-brand") def test_register_private_name_successfully(self): - self.client.login(email="dummy", password="test correct password") # No exception will be raised if this is successful self.client.register("test-good-snap-name", is_private=True) - def test_register_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - self.client.register("test-good-snap-name") - self.assertFalse(self.fake_store.needs_refresh) - def test_already_registered(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, @@ -595,7 +312,6 @@ def test_already_registered(self): ) def test_register_a_reserved_name(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, @@ -614,7 +330,6 @@ def test_register_a_reserved_name(self): ) def test_register_already_owned_name(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, @@ -626,7 +341,6 @@ def test_register_already_owned_name(self): ) def test_registering_too_fast_in_a_row(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, "test-snapcraft-fast" ) @@ -638,7 +352,6 @@ def test_registering_too_fast_in_a_row(self): ) def test_registering_name_too_long(self): - self.client.login(email="dummy", password="test correct password") name = "name-too-l{}ng".format("0" * 40) raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, name @@ -650,7 +363,6 @@ def test_registering_name_too_long(self): self.assertThat(str(raised), Equals(expected)) def test_registering_name_invalid(self): - self.client.login(email="dummy", password="test correct password") name = "test_invalid" raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, name @@ -663,7 +375,6 @@ def test_registering_name_invalid(self): self.assertThat(str(raised), Equals(expected)) def test_unhandled_registration_error_path(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreRegistrationError, self.client.register, @@ -677,7 +388,6 @@ def test_unhandled_registration_error_path(self): class ValidationSetsTestCase(StoreTestCase): def setUp(self): super().setUp() - self.client.login(email="dummy", password="test correct password") self.validation_sets_build = { "name": "acme-cert-2020-10", @@ -842,7 +552,6 @@ def setUp(self): self.useFixture(self.fake_logger) def test_get_success(self): - self.client.login(email="dummy", password="test correct password") expected = [ { "approved-snap-id": "snap-id-1", @@ -888,8 +597,6 @@ def test_get_success(self): self.assertThat(result, Equals(expected)) def test_get_bad_response(self): - self.client.login(email="dummy", password="test correct password") - err = self.assertRaises( errors.StoreValidationError, self.client.get_assertion, "bad", "validations" ) @@ -898,21 +605,7 @@ def test_get_bad_response(self): self.assertThat(str(err), Equals(expected)) self.assertIn("Invalid response from the server", self.fake_logger.output) - def test_get_error_response(self): - self.client.login(email="dummy", password="test correct password") - - err = self.assertRaises( - http_clients.errors.StoreNetworkError, - self.client.get_assertion, - "err", - "validations", - ) - - expected = "maximum retries exceeded" - self.assertThat(str(err), Contains(expected)) - def test_push_success(self): - self.client.login(email="dummy", password="test correct password") assertion = json.dumps({"foo": "bar"}).encode("utf-8") result = self.client.push_assertion("good", assertion, "validations") @@ -921,7 +614,6 @@ def test_push_success(self): self.assertThat(result, Equals(expected)) def test_push_bad_response(self): - self.client.login(email="dummy", password="test correct password") assertion = json.dumps({"foo": "bar"}).encode("utf-8") err = self.assertRaises( @@ -936,191 +628,9 @@ def test_push_bad_response(self): self.assertThat(str(err), Equals(expected)) self.assertIn("Invalid response from the server", self.fake_logger.output) - def test_push_error_response(self): - self.client.login(email="dummy", password="test correct password") - assertion = json.dumps({"foo": "bar"}).encode("utf-8") - - err = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.push_assertion, - "err", - assertion, - "validations", - ) - self.assertThat(err.error_code, Equals(501)) - - -class UploadTestCase(StoreTestCase): - def setUp(self): - super().setUp() - self.snap_path = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap.snap" - ) - # These should eventually converge to the same module - pbars = ( - "snapcraft.storeapi._upload.ProgressBar", - "snapcraft.storeapi._status_tracker.ProgressBar", - ) - for pbar in pbars: - patcher = mock.patch(pbar, new=unit.SilentProgressBar) - patcher.start() - self.addCleanup(patcher.stop) - - def test_upload_snap(self): - self.client.login(email="dummy", password="test correct password") - self.client.register("test-snap") - tracker = self.client.upload("test-snap", self.snap_path) - self.assertTrue(isinstance(tracker, storeapi._status_tracker.StatusTracker)) - result = tracker.track() - expected_result = { - "code": "ready_to_release", - "revision": "1", - "url": "/dev/click-apps/5349/rev/1", - "can_release": True, - "processed": True, - } - self.assertThat(result, Equals(expected_result)) - - # This should not raise - tracker.raise_for_code() - - def test_upload_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.client.register("test-snap") - self.fake_store.needs_refresh = True - tracker = self.client.upload("test-snap", self.snap_path) - result = tracker.track() - expected_result = { - "code": "ready_to_release", - "revision": "1", - "url": "/dev/click-apps/5349/rev/1", - "can_release": True, - "processed": True, - } - self.assertThat(result, Equals(expected_result)) - - # This should not raise - tracker.raise_for_code() - - self.assertFalse(self.fake_store.needs_refresh) - - def test_upload_snap_fails_due_to_upload_fail(self): - # Tells the fake updown server to return a 5xx response - self.useFixture(fixtures.EnvironmentVariable("UPDOWN_BROKEN", "1")) - - self.client.login(email="dummy", password="test correct password") - - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.upload, - "test-snap", - self.snap_path, - ) - self.assertThat(raised.error_code, Equals(500)) - - def test_upload_snap_requires_review(self): - self.client.login(email="dummy", password="test correct password") - self.client.register("test-review-snap") - tracker = self.client.upload("test-review-snap", self.snap_path) - self.assertTrue(isinstance(tracker, storeapi._status_tracker.StatusTracker)) - result = tracker.track() - expected_result = { - "code": "need_manual_review", - "revision": "1", - "url": "/dev/click-apps/5349/rev/1", - "can_release": False, - "processed": True, - } - self.assertThat(result, Equals(expected_result)) - - self.assertRaises(errors.StoreReviewError, tracker.raise_for_code) - - def test_upload_duplicate_snap(self): - self.client.login(email="dummy", password="test correct password") - self.client.register("test-duplicate-snap") - tracker = self.client.upload("test-duplicate-snap", self.snap_path) - self.assertTrue(isinstance(tracker, storeapi._status_tracker.StatusTracker)) - result = tracker.track() - expected_result = { - "code": "processing_error", - "revision": "1", - "url": "/dev/click-apps/5349/rev/1", - "can_release": False, - "processed": True, - "errors": [{"message": "Duplicate snap already uploaded"}], - } - self.assertThat(result, Equals(expected_result)) - - raised = self.assertRaises(errors.StoreReviewError, tracker.raise_for_code) - - self.assertThat( - str(raised), - Equals( - "The store was unable to accept this snap.\n" - " - Duplicate snap already uploaded" - ), - ) - - def test_braces_in_error_messages_are_literals(self): - self.client.login(email="dummy", password="test correct password") - self.client.register("test-scan-error-with-braces") - tracker = self.client.upload("test-scan-error-with-braces", self.snap_path) - self.assertTrue(isinstance(tracker, storeapi._status_tracker.StatusTracker)) - result = tracker.track() - expected_result = { - "code": "processing_error", - "revision": "1", - "url": "/dev/click-apps/5349/rev/1", - "can_release": False, - "processed": True, - "errors": [{"message": "Error message with {braces}"}], - } - self.assertThat(result, Equals(expected_result)) - - raised = self.assertRaises(errors.StoreReviewError, tracker.raise_for_code) - - self.assertThat( - str(raised), - Equals( - "The store was unable to accept this snap.\n" - " - Error message with {braces}" - ), - ) - - def test_upload_unregistered_snap(self): - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - errors.StoreUploadError, - self.client.upload, - "test-snap-unregistered", - self.snap_path, - ) - self.assertThat( - str(raised), - Equals("This snap is not registered. Register the snap and try again."), - ) - - def test_upload_forbidden_snap(self): - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - errors.StoreUploadError, - self.client.upload, - "test-snap-forbidden", - self.snap_path, - ) - self.assertThat( - str(raised), - Equals( - "You are not the publisher or allowed to upload revisions for " - "this snap. Ensure you are logged in with the proper account " - "and try again." - ), - ) - class ReleaseTest(StoreTestCase): def test_release_snap(self): - self.client.login(email="dummy", password="test correct password") channel_map = self.client.release("test-snap", "19", ["beta"]) expected_channel_map = { "opened_channels": ["beta"], @@ -1134,7 +644,6 @@ def test_release_snap(self): self.assertThat(channel_map, Equals(expected_channel_map)) def test_progressive_release_snap(self): - self.client.login(email="dummy", password="test correct password") channel_map = self.client.release( "test-snap", "19", ["beta"], progressive_percentage=10 ) @@ -1151,61 +660,14 @@ def test_progressive_release_snap(self): # done. self.assertThat(channel_map, Equals(expected_channel_map)) - def test_release_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - channel_map = self.client.release("test-snap", "19", ["beta"]) - expected_channel_map = { - "opened_channels": ["beta"], - "channel_map": [ - {"channel": "stable", "info": "none"}, - {"channel": "candidate", "info": "none"}, - {"revision": 19, "channel": "beta", "version": "0", "info": "specific"}, - {"channel": "edge", "info": "tracking"}, - ], - } - self.assertThat(channel_map, Equals(expected_channel_map)) - self.assertFalse(self.fake_store.needs_refresh) - def test_release_snap_to_invalid_channel(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreReleaseError, self.client.release, "test-snap", "19", ["alpha"] ) self.assertThat(str(raised), Equals("Not a valid channel: alpha")) - def test_release_snap_to_bad_channel(self): - self.client.login(email="dummy", password="test correct password") - self.assertRaises( - http_clients.errors.StoreServerError, - self.client.release, - "test-snap", - "19", - ["bad-channel"], - ) - - def test_release_unregistered_snap(self): - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - errors.StoreReleaseError, - self.client.release, - "test-snap-unregistered", - "19", - ["alpha"], - ) - - self.assertThat( - str(raised), - Equals( - "Sorry, try `snapcraft register test-snap-unregistered` " - "before trying to release or choose an existing " - "revision." - ), - ) - def test_release_with_invalid_revision(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreReleaseError, self.client.release, @@ -1220,7 +682,6 @@ def test_release_with_invalid_revision(self): ) def test_release_to_curly_braced_channel(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.StoreReleaseError, self.client.release, @@ -1239,127 +700,6 @@ def test_release_to_curly_braced_channel(self): ) -class CloseChannelsTestCase(StoreTestCase): - def setUp(self): - super().setUp() - self.fake_logger = fixtures.FakeLogger(level=logging.DEBUG) - self.useFixture(self.fake_logger) - - def test_close_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - self.client.close_channels("snap-id", ["dummy"]) - self.assertFalse(self.fake_store.needs_refresh) - - def test_close_invalid_data(self): - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - errors.StoreChannelClosingError, - self.client.close_channels, - "snap-id", - ["invalid"], - ) - self.assertThat( - str(raised), - Equals( - "Could not close channel: The 'channels' field content " "is not valid." - ), - ) - - def test_close_unexpected_data(self): - # The endpoint in SCA would never return plain/text, however anything - # might happen in the internet, so we are a little defensive. - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.close_channels, - "snap-id", - ["unexpected"], - ) - self.assertThat(raised.error_code, Equals(500)) - - def test_close_broken_store_plain(self): - # If the contract is broken by the Store, users will be have additional - # debug information available. - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - errors.StoreChannelClosingError, - self.client.close_channels, - "snap-id", - ["broken-plain"], - ) - self.assertThat(str(raised), Equals("Could not close channel: 200 OK")) - - expected_lines = [ - "Invalid response from the server on channel closing:", - "200 OK", - "b'plain data'", - ] - - actual_lines = [] - for line in self.fake_logger.output.splitlines(): - line = line.strip() - if line in expected_lines: - actual_lines.append(line) - - self.assertThat(actual_lines, Equals(expected_lines)) - - def test_close_broken_store_json(self): - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - errors.StoreChannelClosingError, - self.client.close_channels, - "snap-id", - ["broken-json"], - ) - self.assertThat(str(raised), Equals("Could not close channel: 200 OK")) - - expected_lines = [ - "Invalid response from the server on channel closing:", - "200 OK", - 'b\'{"closed_channels": ["broken-json"]}\'', - ] - - actual_lines = [] - for line in self.fake_logger.output.splitlines(): - line = line.strip() - if line in expected_lines: - actual_lines.append(line) - - self.assertThat(actual_lines, Equals(expected_lines)) - - def test_close_successfully(self): - # Successfully closing a channels returns 'closed_channels' - # and 'channel_map_tree' from the Store. - self.client.login(email="dummy", password="test correct password") - closed_channels, channel_map_tree = self.client.close_channels( - "snap-id", ["beta"] - ) - self.assertThat(closed_channels, Equals(["beta"])) - self.assertThat( - channel_map_tree, - Equals( - { - "latest": { - "16": { - "amd64": [ - {"channel": "stable", "info": "none"}, - {"channel": "candidate", "info": "none"}, - { - "channel": "beta", - "info": "specific", - "revision": 42, - "version": "1.1", - }, - {"channel": "edge", "info": "tracking"}, - ] - } - } - } - ), - ) - - class GetSnapStatusTestCase(StoreTestCase): def setUp(self): super().setUp() @@ -1442,11 +782,9 @@ def setUp(self): } def test_get_snap_status_successfully(self): - self.client.login(email="dummy", password="test correct password") self.assertThat(self.client.get_snap_status("basic"), Equals(self.expected)) def test_get_snap_status_filter_by_arch(self): - self.client.login(email="dummy", password="test correct password") exp_arch = self.expected["channel_map_tree"]["latest"]["16"]["amd64"] self.assertThat( self.client.get_snap_status("basic", arch="amd64"), @@ -1454,7 +792,6 @@ def test_get_snap_status_filter_by_arch(self): ) def test_get_snap_status_filter_by_unknown_arch(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( storeapi.errors.SnapNotFoundError, @@ -1468,54 +805,14 @@ def test_get_snap_status_filter_by_unknown_arch(self): self.expectThat(raised._arch, Is("some-arch")) def test_get_snap_status_no_id(self): - self.client.login(email="dummy", password="test correct password") e = self.assertRaises( storeapi.errors.NoSnapIdError, self.client.get_snap_status, "no-id" ) self.assertThat(e.snap_name, Equals("no-id")) - def test_get_snap_status_refreshes_macaroon(self): - self.client.login(email="dummy", password="test correct password") - self.fake_store.needs_refresh = True - self.assertThat(self.client.get_snap_status("basic"), Equals(self.expected)) - self.assertFalse(self.fake_store.needs_refresh) - - @mock.patch.object(storeapi.StoreClient, "get_account_information") - @mock.patch.object(storeapi._dashboard_api.DashboardAPI, "get") - def test_get_snap_status_server_error(self, mock_sca_get, mock_account_info): - mock_account_info.return_value = { - "snaps": {"16": {"basic": {"snap-id": "my_snap_id"}}} - } - - mock_sca_get.return_value = mock.Mock( - ok=False, status_code=500, reason="Server error", json=lambda: {} - ) - - self.client.login(email="dummy", password="test correct password") - e = self.assertRaises( - storeapi.errors.StoreSnapStatusError, self.client.get_snap_status, "basic" - ) - self.assertThat( - str(e), - Equals( - "Error fetching status of snap id 'my_snap_id' for 'any arch' " - "in '16' series: 500 Server error." - ), - ) - - -class SnapChannelMapTest(StoreTestCase): - def test_get_snap_channel_map(self): - self.client.login(email="dummy", password="test correct password") - self.assertThat( - self.client.get_snap_channel_map(snap_name="basic"), - IsInstance(channel_map.ChannelMap), - ) - class SnapReleasesTest(StoreTestCase): def test_get_snap_releases(self): - self.client.login(email="dummy", password="test correct password") self.assertThat( self.client.get_snap_releases(snap_name="basic"), IsInstance(releases.Releases), @@ -1530,29 +827,11 @@ def test_get_metrics(self): start="2021-01-01", end="2021-01-01", ) - self.client.login(email="dummy", password="test correct password") self.assertThat( self.client.get_metrics(snap_name="basic", filters=[mf]), IsInstance(metrics.MetricsResults), ) - def test_get_metrics_general_error(self): - mf = metrics.MetricsFilter( - snap_id="err", - metric_name="test-name", - start="2021-01-01", - end="2021-01-01", - ) - self.client.login(email="dummy", password="test correct password") - - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.get_metrics, - snap_name="error", - filters=[mf], - ) - self.assertThat(raised.error_code, Equals(503)) - def test_get_metrics_invalid_date_error(self): mf = metrics.MetricsFilter( snap_id="err-invalid-date-interval", @@ -1560,7 +839,6 @@ def test_get_metrics_invalid_date_error(self): start="2021-01-01", end="2021-01-01", ) - self.client.login(email="dummy", password="test correct password") with pytest.raises(errors.StoreMetricsError) as exc_info: self.client.get_metrics(snap_name="error", filters=[mf]) @@ -1590,7 +868,6 @@ def test_get_metrics_unmarshal_error(self): start="2021-01-01", end="2021-01-01", ) - self.client.login(email="dummy", password="test correct password") with pytest.raises(errors.StoreMetricsUnmarshalError) as exc_info: self.client.get_metrics(snap_name="error", filters=[mf]) @@ -1613,7 +890,6 @@ def test_get_metrics_unmarshal_error(self): class WhoAmITest(StoreTestCase): def test_whoami(self): - self.client.login(email="dummy", password="test correct password") self.assertThat( self.client.whoami(), IsInstance(whoami.WhoAmI), @@ -1622,7 +898,6 @@ def test_whoami(self): class SignDeveloperAgreementTestCase(StoreTestCase): def test_sign_dev_agreement_success(self): - self.client.login(email="dummy", password="test correct password") response = { "content": { "latest_tos_accepted": True, @@ -1637,7 +912,6 @@ def test_sign_dev_agreement_success(self): ) def test_sign_dev_agreement_exception(self): - self.client.login(email="dummy", password="test correct password") raised = self.assertRaises( errors.DeveloperAgreementSignError, self.client.sign_developer_agreement, @@ -1649,16 +923,6 @@ def test_sign_dev_agreement_exception(self): str(raised), ) - def test_sign_dev_agreement_exception_store_down(self): - self.useFixture(fixtures.EnvironmentVariable("STORE_DOWN", "1")) - self.client.login(email="dummy", password="test correct password") - raised = self.assertRaises( - http_clients.errors.StoreServerError, - self.client.sign_developer_agreement, - latest_tos_accepted=True, - ) - self.assertThat(raised.error_code, Equals(500)) - class UploadMetadataTestCase(StoreTestCase): def setUp(self): @@ -1671,18 +935,7 @@ def _setup_snap(self): These are all the previous steps needed to upload metadata. """ - self.client.login(email="dummy", password="test correct password") self.client.register("basic") - path = os.path.join(os.path.dirname(tests.__file__), "data", "test-snap.snap") - tracker = self.client.upload("basic", path) - tracker.track() - - def test_refreshes_macaroon(self): - self._setup_snap() - self.fake_store.needs_refresh = True - metadata = {"field_ok": "foo"} - self.client.upload_metadata("basic", metadata, False) - self.assertFalse(self.fake_store.needs_refresh) def test_invalid_data(self): self._setup_snap() @@ -1781,19 +1034,7 @@ def _setup_snap(self): These are all the previous steps needed to upload binary metadata. """ - self.client.login(email="dummy", password="test correct password") self.client.register("basic") - path = os.path.join(os.path.dirname(tests.__file__), "data", "test-snap.snap") - tracker = self.client.upload("basic", path) - tracker.track() - - def test_refreshes_macaroon(self): - self._setup_snap() - self.fake_store.needs_refresh = True - with tempfile.NamedTemporaryFile(suffix="ok") as f: - metadata = {"icon": f} - self.client.upload_binary_metadata("basic", metadata, False) - self.assertFalse(self.fake_store.needs_refresh) def test_invalid_data(self): self._setup_snap() @@ -1876,11 +1117,7 @@ def _setup_snap(self): These are all the previous steps needed to upload binary metadata. """ - self.client.login(email="dummy", password="test correct password") self.client.register("basic") - path = os.path.join(os.path.dirname(tests.__file__), "data", "test-snap.snap") - tracker = self.client.upload("basic", path) - tracker.track() def assert_raises(self, method): self._setup_snap() diff --git a/tests/unit/store/v2/__init__.py b/tests/legacy/unit/store/v2/__init__.py similarity index 100% rename from tests/unit/store/v2/__init__.py rename to tests/legacy/unit/store/v2/__init__.py diff --git a/tests/unit/store/v2/test_releases.py b/tests/legacy/unit/store/v2/test_releases.py similarity index 99% rename from tests/unit/store/v2/test_releases.py rename to tests/legacy/unit/store/v2/test_releases.py index ad692e38b0..28bbe5676d 100644 --- a/tests/unit/store/v2/test_releases.py +++ b/tests/legacy/unit/store/v2/test_releases.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.storeapi.v2 import releases +from snapcraft_legacy.storeapi.v2 import releases @pytest.mark.parametrize( diff --git a/tests/unit/store/v2/test_validation_sets.py b/tests/legacy/unit/store/v2/test_validation_sets.py similarity index 98% rename from tests/unit/store/v2/test_validation_sets.py rename to tests/legacy/unit/store/v2/test_validation_sets.py index a6598f8e63..e63db9188f 100644 --- a/tests/unit/store/v2/test_validation_sets.py +++ b/tests/legacy/unit/store/v2/test_validation_sets.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.storeapi.v2 import validation_sets +from snapcraft_legacy.storeapi.v2 import validation_sets @pytest.mark.parametrize("snap_id", (None, "snap_id")) diff --git a/tests/unit/store/v2/test_whoami.py b/tests/legacy/unit/store/v2/test_whoami.py similarity index 97% rename from tests/unit/store/v2/test_whoami.py rename to tests/legacy/unit/store/v2/test_whoami.py index 2e5bf7f884..11620e1549 100644 --- a/tests/unit/store/v2/test_whoami.py +++ b/tests/legacy/unit/store/v2/test_whoami.py @@ -17,7 +17,7 @@ import pytest from jsonschema.exceptions import ValidationError -from snapcraft.storeapi.v2 import whoami +from snapcraft_legacy.storeapi.v2 import whoami @pytest.fixture diff --git a/tests/unit/test_common.py b/tests/legacy/unit/test_common.py similarity index 98% rename from tests/unit/test_common.py rename to tests/legacy/unit/test_common.py index 0be663d380..0ecd312254 100644 --- a/tests/unit/test_common.py +++ b/tests/legacy/unit/test_common.py @@ -19,8 +19,8 @@ import pytest from testtools.matchers import Equals -from snapcraft.internal import common, errors -from tests import unit +from snapcraft_legacy.internal import common, errors +from tests.legacy import unit class CommonTestCase(unit.TestCase): diff --git a/tests/unit/test_config.py b/tests/legacy/unit/test_config.py similarity index 95% rename from tests/unit/test_config.py rename to tests/legacy/unit/test_config.py index 3e3db651e4..960bc22a38 100644 --- a/tests/unit/test_config.py +++ b/tests/legacy/unit/test_config.py @@ -19,9 +19,9 @@ import xdg from testtools.matchers import Equals -from snapcraft import config -from snapcraft.internal.errors import SnapcraftInvalidCLIConfigError -from tests import unit +from snapcraft_legacy import config +from snapcraft_legacy.internal.errors import SnapcraftInvalidCLIConfigError +from tests.legacy import unit class TestCLIConfig(unit.TestCase): diff --git a/tests/unit/test_elf.py b/tests/legacy/unit/test_elf.py similarity index 97% rename from tests/unit/test_elf.py rename to tests/legacy/unit/test_elf.py index 03622bf291..2f2089cb6f 100644 --- a/tests/unit/test_elf.py +++ b/tests/legacy/unit/test_elf.py @@ -24,9 +24,9 @@ import pytest from testtools.matchers import Contains, EndsWith, Equals, NotEquals, StartsWith -from snapcraft import ProjectOptions -from snapcraft.internal import elf, errors -from tests import fixture_setup, unit +from snapcraft_legacy import ProjectOptions +from snapcraft_legacy.internal import elf, errors +from tests.legacy import fixture_setup, unit class TestElfBase(unit.TestCase): @@ -68,9 +68,9 @@ def test_extract_ld_library_paths(self): class TestElfFileSmoketest(unit.TestCase): def test_bin_echo(self): # Try parsing a file without the pyelftools logic mocked out - elf_file = elf.ElfFile(path=sys.executable) + elf_file = elf.ElfFile(path="/bin/ls") - self.assertThat(elf_file.path, Equals(sys.executable)) + self.assertThat(elf_file.path, Equals("/bin/ls")) # The arch attribute will be a tuple of three strings self.assertTrue(isinstance(elf_file.arch, tuple)) @@ -110,7 +110,7 @@ def test_bin_echo(self): self.assertTrue(isinstance(elf_file.has_debug_info, bool)) # Ensure type is detered as executable. - self.assertThat(elf_file.elf_type, Equals("ET_EXEC")) + self.assertThat(elf_file.elf_type, Equals("ET_DYN")) class TestInvalidElf(unit.TestCase): @@ -262,7 +262,9 @@ def _is_valid_elf(self, resolved_path: str) -> bool: else: return super()._is_valid_elf(resolved_path) - with mock.patch("snapcraft.internal.elf.Library", side_effect=MooLibrary): + with mock.patch( + "snapcraft_legacy.internal.elf.Library", side_effect=MooLibrary + ): libs = elf_file.load_dependencies( root_path=self.fake_elf.root_path, core_base_path=self.fake_elf.core_base_path, @@ -290,7 +292,7 @@ def test_is_valid_elf_ignores_corrupt_files(self): self.useFixture( fixtures.MockPatch( - "snapcraft.internal.elf.ElfFile", + "snapcraft_legacy.internal.elf.ElfFile", side_effect=errors.CorruptedElfFileError( path=soname_path, error=RuntimeError() ), @@ -526,7 +528,9 @@ def _setup_libc6(self): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.internal.repo.Repo.get_package_libraries") + patcher = mock.patch( + "snapcraft_legacy.internal.repo.Repo.get_package_libraries" + ) self.get_packages_mock = patcher.start() self.get_packages_mock.return_value = self._setup_libc6() self.addCleanup(patcher.stop) diff --git a/tests/unit/test_errors.py b/tests/legacy/unit/test_errors.py similarity index 99% rename from tests/unit/test_errors.py rename to tests/legacy/unit/test_errors.py index 51211f2e88..56da142dba 100644 --- a/tests/unit/test_errors.py +++ b/tests/legacy/unit/test_errors.py @@ -18,10 +18,12 @@ from subprocess import CalledProcessError from typing import List -from snapcraft.internal import errors, pluginhandler, steps -from snapcraft.internal.project_loader import errors as project_loader_errors -from snapcraft.internal.project_loader.inspection import errors as inspection_errors -from snapcraft.internal.repo import errors as repo_errors +from snapcraft_legacy.internal import errors, pluginhandler, steps +from snapcraft_legacy.internal.project_loader import errors as project_loader_errors +from snapcraft_legacy.internal.project_loader.inspection import ( + errors as inspection_errors, +) +from snapcraft_legacy.internal.repo import errors as repo_errors def test_details_from_called_process_error(): diff --git a/tests/unit/test_file_utils.py b/tests/legacy/unit/test_file_utils.py similarity index 98% rename from tests/unit/test_file_utils.py rename to tests/legacy/unit/test_file_utils.py index 613a05a2e6..f5c1c479e7 100644 --- a/tests/unit/test_file_utils.py +++ b/tests/legacy/unit/test_file_utils.py @@ -25,9 +25,9 @@ import testtools from testtools.matchers import Equals -from snapcraft import file_utils -from snapcraft.internal import common, errors -from tests import fixture_setup, unit +from snapcraft_legacy import file_utils +from snapcraft_legacy.internal import common, errors +from tests.legacy import fixture_setup, unit class TestReplaceInFile: diff --git a/tests/unit/test_fixture_setup.py b/tests/legacy/unit/test_fixture_setup.py similarity index 98% rename from tests/unit/test_fixture_setup.py rename to tests/legacy/unit/test_fixture_setup.py index ffe39667d3..a95c4f8d91 100644 --- a/tests/unit/test_fixture_setup.py +++ b/tests/legacy/unit/test_fixture_setup.py @@ -24,7 +24,7 @@ import fixtures from testtools.matchers import Equals -from tests import fixture_setup, unit +from tests.legacy import fixture_setup, unit class TempCWDTestCase(unit.TestCase): diff --git a/tests/unit/test_formatting_utils.py b/tests/legacy/unit/test_formatting_utils.py similarity index 97% rename from tests/unit/test_formatting_utils.py rename to tests/legacy/unit/test_formatting_utils.py index 7269eeda4e..f5607b2b56 100644 --- a/tests/unit/test_formatting_utils.py +++ b/tests/legacy/unit/test_formatting_utils.py @@ -17,8 +17,8 @@ import pytest from testtools.matchers import Equals -from snapcraft import formatting_utils -from tests import unit +from snapcraft_legacy import formatting_utils +from tests.legacy import unit class HumanizeListTestCases(unit.TestCase): diff --git a/tests/unit/test_indicators.py b/tests/legacy/unit/test_indicators.py similarity index 97% rename from tests/unit/test_indicators.py rename to tests/legacy/unit/test_indicators.py index ea9db69d1c..7243b12e03 100644 --- a/tests/unit/test_indicators.py +++ b/tests/legacy/unit/test_indicators.py @@ -21,8 +21,8 @@ import progressbar import requests -from snapcraft.internal import indicators -from tests import unit +from snapcraft_legacy.internal import indicators +from tests.legacy import unit class DumbTerminalTests(unit.TestCase): diff --git a/tests/unit/test_init.py b/tests/legacy/unit/test_init.py similarity index 88% rename from tests/unit/test_init.py rename to tests/legacy/unit/test_init.py index 88066c2baa..26bf7605f8 100644 --- a/tests/unit/test_init.py +++ b/tests/legacy/unit/test_init.py @@ -17,12 +17,12 @@ import fixtures from testtools.matchers import Equals -import snapcraft -from tests import unit +import snapcraft_legacy +from tests.legacy import unit class VersionTestCase(unit.TestCase): def test_version_from_snap(self): self.useFixture(fixtures.EnvironmentVariable("SNAP_NAME", "snapcraft")) self.useFixture(fixtures.EnvironmentVariable("SNAP_VERSION", "3.14")) - self.assertThat(snapcraft._get_version(), Equals("3.14")) + self.assertThat(snapcraft_legacy._get_version(), Equals("3.14")) diff --git a/tests/unit/test_log.py b/tests/legacy/unit/test_log.py similarity index 98% rename from tests/unit/test_log.py rename to tests/legacy/unit/test_log.py index b62a237f4c..fb7dcad8b7 100644 --- a/tests/unit/test_log.py +++ b/tests/legacy/unit/test_log.py @@ -18,8 +18,8 @@ from testtools.matchers import Contains, Equals, Not -from snapcraft.internal import log -from tests import fixture_setup, unit +from snapcraft_legacy.internal import log +from tests.legacy import fixture_setup, unit class LogTestCase(unit.TestCase): diff --git a/tests/unit/test_mangling.py b/tests/legacy/unit/test_mangling.py similarity index 98% rename from tests/unit/test_mangling.py rename to tests/legacy/unit/test_mangling.py index 942fe9634b..e60b7f8f36 100644 --- a/tests/unit/test_mangling.py +++ b/tests/legacy/unit/test_mangling.py @@ -19,8 +19,8 @@ from testtools.matchers import FileContains, FileExists, Not -from snapcraft.internal import mangling -from tests import fixture_setup, unit +from snapcraft_legacy.internal import mangling +from tests.legacy import fixture_setup, unit def _create_file(filename, contents): diff --git a/tests/unit/test_mountinfo.py b/tests/legacy/unit/test_mountinfo.py similarity index 98% rename from tests/unit/test_mountinfo.py rename to tests/legacy/unit/test_mountinfo.py index 209cc13408..1390270180 100644 --- a/tests/unit/test_mountinfo.py +++ b/tests/legacy/unit/test_mountinfo.py @@ -20,8 +20,8 @@ import fixtures from testtools.matchers import Equals, HasLength -from snapcraft.internal import errors, mountinfo -from tests import unit +from snapcraft_legacy.internal import errors, mountinfo +from tests.legacy import unit class MountInfoTestCase(unit.TestCase): diff --git a/tests/unit/test_options.py b/tests/legacy/unit/test_options.py similarity index 91% rename from tests/unit/test_options.py rename to tests/legacy/unit/test_options.py index 2002b44d27..c8f558ada6 100644 --- a/tests/unit/test_options.py +++ b/tests/legacy/unit/test_options.py @@ -21,14 +21,14 @@ import testtools from testtools.matchers import Equals -import snapcraft -from snapcraft.internal import common -from snapcraft.internal.errors import SnapcraftEnvironmentError -from snapcraft.project._project_options import ( +import snapcraft_legacy +from snapcraft_legacy.internal import common +from snapcraft_legacy.internal.errors import SnapcraftEnvironmentError +from snapcraft_legacy.project._project_options import ( _32BIT_USERSPACE_ARCHITECTURE, _get_platform_architecture, ) -from tests import unit +from tests.legacy import unit class TestNativeOptions: @@ -181,7 +181,7 @@ def test_architecture_options( monkeypatch.setattr(platform, "architecture", lambda: architecture) monkeypatch.setattr(platform, "machine", lambda: machine) - options = snapcraft.ProjectOptions() + options = snapcraft_legacy.ProjectOptions() assert options.arch_triplet == expected_arch_triplet assert options.deb_arch == expected_deb_arch @@ -221,7 +221,7 @@ def test_get_platform_architecture( class OptionsTestCase(unit.TestCase): def test_cross_compiler_prefix_missing(self): - options = snapcraft.ProjectOptions(target_deb_arch="x86_64") + options = snapcraft_legacy.ProjectOptions(target_deb_arch="x86_64") with testtools.ExpectedException( SnapcraftEnvironmentError, @@ -236,7 +236,7 @@ def test_cross_compiler_prefix_empty( ): mock_platform_machine.return_value = "x86_64" mock_platform_architecture.return_value = ("64bit", "ELF") - options = snapcraft.ProjectOptions(target_deb_arch="i386") + options = snapcraft_legacy.ProjectOptions(target_deb_arch="i386") self.assertThat(options.cross_compiler_prefix, Equals("")) @@ -263,13 +263,13 @@ class TestHostIsCompatibleWithTargetBase: def test_compatibility(self, monkeypatch, codename, base, is_compatible): monkeypatch.setattr( - snapcraft.internal.os_release.OsRelease, + snapcraft_legacy.internal.os_release.OsRelease, "version_codename", lambda x: codename, ) assert ( - snapcraft.ProjectOptions().is_host_compatible_with_base(base) + snapcraft_legacy.ProjectOptions().is_host_compatible_with_base(base) is is_compatible ) @@ -277,20 +277,20 @@ def test_compatibility(self, monkeypatch, codename, base, is_compatible): class TestLinkerVersionForBase(unit.TestCase): def setUp(self): super().setUp() - patcher = mock.patch("snapcraft.file_utils.get_linker_version_from_file") + patcher = mock.patch("snapcraft_legacy.file_utils.get_linker_version_from_file") self.get_linker_version_mock = patcher.start() self.addCleanup(patcher.stop) def test_get_linker_version_for_core20(self): self.assertThat( - snapcraft.ProjectOptions()._get_linker_version_for_base("core20"), + snapcraft_legacy.ProjectOptions()._get_linker_version_for_base("core20"), Equals("2.31"), ) self.get_linker_version_mock.assert_not_called() def test_get_linker_version_for_core18(self): self.assertThat( - snapcraft.ProjectOptions()._get_linker_version_for_base("core18"), + snapcraft_legacy.ProjectOptions()._get_linker_version_for_base("core18"), Equals("2.27"), ) self.get_linker_version_mock.assert_not_called() @@ -298,7 +298,7 @@ def test_get_linker_version_for_core18(self): def test_get_linker_version_for_random_core(self): self.get_linker_version_mock.return_value = "4.10" self.assertThat( - snapcraft.ProjectOptions()._get_linker_version_for_base("random"), + snapcraft_legacy.ProjectOptions()._get_linker_version_for_base("random"), Equals("4.10"), ) self.get_linker_version_mock.assert_called_once_with("ld-2.23.so") diff --git a/tests/legacy/unit/test_os_release.py b/tests/legacy/unit/test_os_release.py new file mode 100644 index 0000000000..853ed9fee8 --- /dev/null +++ b/tests/legacy/unit/test_os_release.py @@ -0,0 +1,139 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2015-2018 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from textwrap import dedent + +from testtools.matchers import Equals + +from snapcraft_legacy.internal import errors, os_release +from tests.legacy import unit + + +class OsReleaseTestCase(unit.TestCase): + def _write_os_release(self, contents): + path = "os-release" + with open(path, "w") as f: + f.write(contents) + return path + + def test_blank_lines(self): + release = os_release.OsRelease( + os_release_file=self._write_os_release( + dedent( + """\ + NAME="Arch Linux" + + PRETTY_NAME="Arch Linux" + ID=arch + ID_LIKE=archlinux + VERSION_ID="foo" + VERSION_CODENAME="bar" + + """ + ) + ) + ) + + self.assertThat(release.id(), Equals("arch")) + self.assertThat(release.name(), Equals("Arch Linux")) + self.assertThat(release.version_id(), Equals("foo")) + self.assertThat(release.version_codename(), Equals("bar")) + + def test_no_id(self): + release = os_release.OsRelease( + os_release_file=self._write_os_release( + dedent( + """\ + NAME="Arch Linux" + PRETTY_NAME="Arch Linux" + ID_LIKE=archlinux + VERSION_ID="foo" + VERSION_CODENAME="bar" + """ + ) + ) + ) + + self.assertRaises(errors.OsReleaseIdError, release.id) + + def test_no_name(self): + release = os_release.OsRelease( + os_release_file=self._write_os_release( + dedent( + """\ + ID=arch + PRETTY_NAME="Arch Linux" + ID_LIKE=archlinux + VERSION_ID="foo" + VERSION_CODENAME="bar" + """ + ) + ) + ) + + self.assertRaises(errors.OsReleaseNameError, release.name) + + def test_no_version_id(self): + release = os_release.OsRelease( + os_release_file=self._write_os_release( + dedent( + """\ + NAME="Arch Linux" + ID=arch + PRETTY_NAME="Arch Linux" + ID_LIKE=archlinux + VERSION_CODENAME="bar" + """ + ) + ) + ) + + self.assertRaises(errors.OsReleaseVersionIdError, release.version_id) + + def test_no_version_codename(self): + """Test that version codename can also come from VERSION_ID""" + release = os_release.OsRelease( + os_release_file=self._write_os_release( + dedent( + """\ + NAME="Ubuntu" + VERSION="14.04.5 LTS, Trusty Tahr" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 14.04.5 LTS" + VERSION_ID="14.04" + """ + ) + ) + ) + + self.assertThat(release.version_codename(), Equals("trusty")) + + def test_no_version_codename_or_version_id(self): + release = os_release.OsRelease( + os_release_file=self._write_os_release( + dedent( + """\ + NAME="Ubuntu" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 16.04.3 LTS" + """ + ) + ) + ) + + self.assertRaises(errors.OsReleaseCodenameError, release.version_codename) diff --git a/tests/unit/test_steps.py b/tests/legacy/unit/test_steps.py similarity index 97% rename from tests/unit/test_steps.py rename to tests/legacy/unit/test_steps.py index 497f151d37..eb8f6df922 100644 --- a/tests/unit/test_steps.py +++ b/tests/legacy/unit/test_steps.py @@ -16,7 +16,7 @@ import pytest -from snapcraft.internal import steps +from snapcraft_legacy.internal import steps def test_step_order(): diff --git a/tests/unit/test_target_arch.py b/tests/legacy/unit/test_target_arch.py similarity index 95% rename from tests/unit/test_target_arch.py rename to tests/legacy/unit/test_target_arch.py index 20ea037ae0..039fff1455 100644 --- a/tests/unit/test_target_arch.py +++ b/tests/legacy/unit/test_target_arch.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.project._project_options import _find_machine +from snapcraft_legacy.project._project_options import _find_machine class TestFindMachine: diff --git a/tests/unit/test_xattrs.py b/tests/legacy/unit/test_xattrs.py similarity index 96% rename from tests/unit/test_xattrs.py rename to tests/legacy/unit/test_xattrs.py index 4608508a73..9d15fe95f0 100644 --- a/tests/unit/test_xattrs.py +++ b/tests/legacy/unit/test_xattrs.py @@ -20,9 +20,9 @@ from testtools.matchers import Equals -from snapcraft.internal import xattrs -from snapcraft.internal.errors import XAttributeTooLongError -from tests import unit +from snapcraft_legacy.internal import xattrs +from snapcraft_legacy.internal.errors import XAttributeTooLongError +from tests.legacy import unit class TestXattrs(unit.TestCase): diff --git a/tests/unit/yaml_utils/__init__.py b/tests/legacy/unit/yaml_utils/__init__.py similarity index 100% rename from tests/unit/yaml_utils/__init__.py rename to tests/legacy/unit/yaml_utils/__init__.py diff --git a/tests/unit/yaml_utils/test_errors.py b/tests/legacy/unit/yaml_utils/test_errors.py similarity index 94% rename from tests/unit/yaml_utils/test_errors.py rename to tests/legacy/unit/yaml_utils/test_errors.py index 1e2f7c3b9e..7871057bdc 100644 --- a/tests/unit/yaml_utils/test_errors.py +++ b/tests/legacy/unit/yaml_utils/test_errors.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.yaml_utils import errors +from snapcraft_legacy.yaml_utils import errors def test_YamlvalidationError(): diff --git a/tests/unit/yaml_utils/test_yaml_utils.py b/tests/legacy/unit/yaml_utils/test_yaml_utils.py similarity index 96% rename from tests/unit/yaml_utils/test_yaml_utils.py rename to tests/legacy/unit/yaml_utils/test_yaml_utils.py index 35310dea7e..c26628c13a 100644 --- a/tests/unit/yaml_utils/test_yaml_utils.py +++ b/tests/legacy/unit/yaml_utils/test_yaml_utils.py @@ -15,11 +15,11 @@ # along with this program. If not, see . import io -import pytest +import pytest -from snapcraft import yaml_utils -from snapcraft.yaml_utils import YamlValidationError +from snapcraft_legacy import yaml_utils +from snapcraft_legacy.yaml_utils import YamlValidationError def test_load_yaml_file(caplog, tmp_path): diff --git a/tests/skip.py b/tests/skip.py index a1e94a7f6d..3a1de86222 100644 --- a/tests/skip.py +++ b/tests/skip.py @@ -22,7 +22,7 @@ def skip_unless_codename(codename, message: str) -> Callable[..., Callable[..., None]]: - if type(codename) is str: + if isinstance(codename, str): codename = [codename] def _wrap(func: Callable[..., None]) -> Callable[..., None]: diff --git a/tests/spread/core22/appstream-desktop/expected_appstream-desktop.desktop b/tests/spread/core22/appstream-desktop/expected_appstream-desktop.desktop new file mode 100644 index 0000000000..c11175ebd0 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/expected_appstream-desktop.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Name=appstream-desktop +Exec=appstream-desktop +Type=Application +Icon=${SNAP}/meta/gui/icon.svg + diff --git a/tests/spread/core22/appstream-desktop/expected_snap.yaml b/tests/spread/core22/appstream-desktop/expected_snap.yaml new file mode 100644 index 0000000000..81a303ae70 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/expected_snap.yaml @@ -0,0 +1,28 @@ +name: appstream-desktop +title: Appstream Desktop +version: 1.0.0 +summary: Appstream Desktop test +description: |- + Some description. + + + Some list: + + - First item + - Second item + + + Test me please. +architectures: +- amd64 +base: core22 +assumes: +- command-chain +apps: + appstream-desktop: + command: usr/bin/appstream-desktop + common-id: io.snapcraft.appstream + command-chain: + - snap/command-chain/snapcraft-runner +confinement: strict +grade: devel diff --git a/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/appstream-desktop b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/appstream-desktop new file mode 100755 index 0000000000..10501a451d --- /dev/null +++ b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/appstream-desktop @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "appstream desktop" diff --git a/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/io.snapcraft.appstream.desktop b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/io.snapcraft.appstream.desktop new file mode 100644 index 0000000000..85db82eab9 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/io.snapcraft.appstream.desktop @@ -0,0 +1,5 @@ +[Desktop Entry] +Name=appstream-desktop +Exec=appstream +Type=Application +Icon=/usr/share/icons/hicolor/scalable/apps/snapcraft.svg diff --git a/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/io.snapcraft.appstream.metainfo.xml b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/io.snapcraft.appstream.metainfo.xml new file mode 100644 index 0000000000..11affa5ea9 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/io.snapcraft.appstream.metainfo.xml @@ -0,0 +1,48 @@ + + + + io.snapcraft.appstream + FSFAP + GPL-2.0+ + Appstream Desktop + Appstream Desktop test + + +

      + Some description. +

      +

      Some list:

      +
        +
      • First item
      • +
      • Second item
      • +
      +

      + Test me please. +

      +
      + + /usr/share/icons/hicolor/scalable/apps/snapcraft.svg + io.snapcraft.appstream.desktop + + + + snapcraft + https://admin.insights.ubuntu.com/wp-content/uploads/3124/snapcraft_db_brandmark@4x.png + + + + https://snapcraft.io + snapcraft + + + appstream + + + + + +

      Initial release.

      +
      +
      +
      +
      diff --git a/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/snap/snapcraft.yaml b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/snap/snapcraft.yaml new file mode 100644 index 0000000000..d5329c80d4 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/snap/snapcraft.yaml @@ -0,0 +1,23 @@ +name: appstream-desktop +base: core22 + +grade: devel +confinement: strict +adopt-info: appstream-desktop + +apps: + appstream-desktop: + command: usr/bin/appstream-desktop + common-id: io.snapcraft.appstream + desktop: usr/share/applications/io.snapcraft.appstream.desktop + +parts: + appstream-desktop: + plugin: nil + source: . + parse-info: [usr/share/metainfo/io.snapcraft.appstream.metainfo.xml] + override-build: | + install -D -m 0644 io.snapcraft.appstream.desktop $CRAFT_PART_INSTALL/usr/share/applications/io.snapcraft.appstream.desktop + install -D -m 0644 io.snapcraft.appstream.metainfo.xml $CRAFT_PART_INSTALL/usr/share/metainfo/io.snapcraft.appstream.metainfo.xml + install -D -m 0644 snapcraft.svg $CRAFT_PART_INSTALL/usr/share/icons/hicolor/scalable/apps/snapcraft.svg + install -D -m 0755 appstream-desktop $CRAFT_PART_INSTALL/usr/bin/appstream-desktop diff --git a/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/snapcraft.svg b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/snapcraft.svg new file mode 100644 index 0000000000..38cdee4083 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/snaps/appstream-desktop/snapcraft.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/tests/spread/core22/appstream-desktop/task.yaml b/tests/spread/core22/appstream-desktop/task.yaml new file mode 100644 index 0000000000..a700e69059 --- /dev/null +++ b/tests/spread/core22/appstream-desktop/task.yaml @@ -0,0 +1,27 @@ +summary: Build a snap that tests appstream settings + +environment: + SNAP_DIR: snaps/appstream-desktop + +restore: | + cd "$SNAP_DIR" + snapcraft clean + rm -f ./*.snap + +execute: | + cd "$SNAP_DIR" + snapcraft prime --destructive-mode + + expected_snap_yaml="../../expected_snap.yaml" + + if ! diff -U10 prime/meta/snap.yaml "$expected_snap_yaml"; then + echo "The formatting for snap.yaml is incorrect" + exit 1 + fi + + expected_desktop="../../expected_appstream-desktop.desktop" + + if ! diff -U10 prime/meta/gui/appstream-desktop.desktop "$expected_desktop"; then + echo "The formatting for appstream-desktop.desktop is incorrect" + exit 1 + fi diff --git a/tests/spread/core22/clean/snap/snapcraft.yaml b/tests/spread/core22/clean/snap/snapcraft.yaml new file mode 100644 index 0000000000..028287c079 --- /dev/null +++ b/tests/spread/core22/clean/snap/snapcraft.yaml @@ -0,0 +1,14 @@ +name: clean +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +parts: + part1: + plugin: nil + + part2: + plugin: nil diff --git a/tests/spread/core22/clean/task.yaml b/tests/spread/core22/clean/task.yaml new file mode 100644 index 0000000000..41ce466a19 --- /dev/null +++ b/tests/spread/core22/clean/task.yaml @@ -0,0 +1,63 @@ +summary: Test craftctl commands on core22 + +environment: + +prepare: | + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + # set_base "$SNAP/snap/snapcraft.yaml" + snap install core22 --edge + +restore: | + rm -f ./*.snap + rm -Rf work + + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml "snap/snapcraft.yaml" + +execute: | + # Unset SNAPCRAFT_BUILD_ENVIRONMENT=host. + unset SNAPCRAFT_BUILD_ENVIRONMENT + + snapcraft pack + snapcraft clean part1 + lxc --project=snapcraft list | grep snapcraft-clean + + snapcraft pack 2>&1 | tee output.txt + + grep "Executing parts lifecycle: pull part1" < output.txt + grep "Executing parts lifecycle: skip pull part2 (already ran)" < output.txt + grep "Executing parts lifecycle: overlay part1" < output.txt + grep "Executing parts lifecycle: skip overlay part2 (already ran)" < output.txt + grep "Executing parts lifecycle: build part1" < output.txt + grep "Executing parts lifecycle: skip build part2 (already ran)" < output.txt + grep "Executing parts lifecycle: stage part1" < output.txt + grep "Executing parts lifecycle: skip stage part2 (already ran)" < output.txt + grep "Executing parts lifecycle: prime part1" < output.txt + grep "Executing parts lifecycle: skip prime part2 (already ran)" < output.txt + + snapcraft clean + if lxc --project=snapcraft list | grep snapcraft-clean; then + echo "instance not removed" + exit 1 + fi + + # also try it in destructive mode + test ! -d parts && test ! -d stage && test ! -d prime + + snapcraft pack --destructive-mode + + test -d parts && test -d stage && test -d prime + test ! -z "$(ls -A parts/part1/state)" + test ! -z "$(ls -A parts/part1/state)" + + snapcraft clean --destructive-mode part1 + + test -d parts && test -d stage && test -d prime + test -z "$(ls -A parts/part1/state)" + test ! -z "$(ls -A parts/part2/state)" + + snapcraft clean --destructive-mode + + test ! -d parts && test ! -d stage && test ! -d prime diff --git a/tests/spread/core22/craftctl/task.yaml b/tests/spread/core22/craftctl/task.yaml new file mode 100644 index 0000000000..21e05dfe3b --- /dev/null +++ b/tests/spread/core22/craftctl/task.yaml @@ -0,0 +1,30 @@ +summary: Test craftctl commands on core22 + +environment: + SNAP/test_craftctl_default: test-craftctl-default + SNAP/test_craftctl_get_set: test-craftctl-get-set + +prepare: | + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + # set_base "$SNAP/snap/snapcraft.yaml" + snap install core22 --edge + +restore: | + cd "$SNAP" + rm -f ./*.snap + rm -Rf work + + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml "snap/snapcraft.yaml" + +execute: | + cd "$SNAP" + + if [ "$SPREAD_SYSTEM" = "ubuntu-20.04-64" ]; then + snapcraft --verbose --destructive-mode + TESTBIN="${SNAP##*test-}" + snap install craftctl-*.snap --dangerous + $TESTBIN | grep hello + fi diff --git a/tests/spread/core22/craftctl/test-craftctl-default/Makefile b/tests/spread/core22/craftctl/test-craftctl-default/Makefile new file mode 100644 index 0000000000..aa47d9c2bc --- /dev/null +++ b/tests/spread/core22/craftctl/test-craftctl-default/Makefile @@ -0,0 +1,19 @@ +CC = gcc +CFLAGS = -O2 -Wall +LD = gcc +LDFLAGS = +OBJS = hello.o +BIN = hello + +.c.o: + $(CC) -c $(CFLAGS) -o$*.o $< + +$(BIN): $(OBJS) + $(LD) -o $@ $(OBJS) + +install: + mkdir -p $(DESTDIR)/usr/bin + install -m755 $(BIN) $(DESTDIR)/usr/bin + +clean: + rm -f $(OBJS) diff --git a/tests/spread/core22/craftctl/test-craftctl-default/hello.c b/tests/spread/core22/craftctl/test-craftctl-default/hello.c new file mode 100644 index 0000000000..7583bb3313 --- /dev/null +++ b/tests/spread/core22/craftctl/test-craftctl-default/hello.c @@ -0,0 +1,6 @@ +#include + +int main() +{ + printf("hello\n"); +} diff --git a/tests/spread/core22/craftctl/test-craftctl-default/snap/snapcraft.yaml b/tests/spread/core22/craftctl/test-craftctl-default/snap/snapcraft.yaml new file mode 100644 index 0000000000..1dbb998f5d --- /dev/null +++ b/tests/spread/core22/craftctl/test-craftctl-default/snap/snapcraft.yaml @@ -0,0 +1,28 @@ +name: craftctl-default +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +apps: + craftctl-default: + command: usr/bin/hello + +parts: + hello: + plugin: make + source: . + override-pull: | + echo "This is the pull step" + craftctl default + override-build: | + echo "This is the build step" + craftctl default + override-stage: | + echo "This is the stage step" + craftctl default + override-prime: | + echo "This is the prime step" + craftctl default diff --git a/tests/spread/core22/craftctl/test-craftctl-get-set/snap/snapcraft.yaml b/tests/spread/core22/craftctl/test-craftctl-get-set/snap/snapcraft.yaml new file mode 100644 index 0000000000..0e17298a5a --- /dev/null +++ b/tests/spread/core22/craftctl/test-craftctl-get-set/snap/snapcraft.yaml @@ -0,0 +1,26 @@ +name: craftctl-get-set +summary: test +description: test +grade: devel +confinement: strict +base: core22 +adopt-info: hello + +apps: + craftctl-get-set: + command: hello.sh + +parts: + hello: + plugin: nil + override-pull: | + echo -e "#!/usr/bin/env bash\necho hello" > hello.sh + chmod +x hello.sh + craftctl get grade | grep devel + craftctl set version="22" + craftctl set grade=stable + override-build: | + craftctl get version | grep 22 + craftctl get grade | grep stable + echo "This is the build step" + cp hello.sh "$CRAFT_PART_INSTALL"/ diff --git a/tests/spread/core22/environment/task.yaml b/tests/spread/core22/environment/task.yaml new file mode 100644 index 0000000000..116730bac7 --- /dev/null +++ b/tests/spread/core22/environment/task.yaml @@ -0,0 +1,58 @@ +summary: Test scriptlets variables on core22 + +environment: + SNAP/test_variables: test-variables + +prepare: | + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + # set_base "$SNAP/snap/snapcraft.yaml" + snap install core22 --edge + +restore: | + cd "$SNAP" + rm -f ./*.snap + rm -Rf work + + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml "snap/snapcraft.yaml" + +execute: | + cd "$SNAP" + + check_vars() { + file="$1" + echo "==== $file ====" + cat "$file" + for exp in \ + "^CRAFT_ARCH_TRIPLET=x86_64-linux-gnu$" \ + "^CRAFT_TARGET_ARCH=amd64$" \ + "^CRAFT_PARALLEL_BUILD_COUNT=[0-9]\+$" \ + "^CRAFT_PROJECT_DIR=/root/project$" \ + "^CRAFT_PART_NAME=hello$" \ + "^CRAFT_PART_SRC=/root/parts/hello/src$" \ + "^CRAFT_PART_SRC_WORK=/root/parts/hello/src$" \ + "^CRAFT_PART_BUILD=/root/parts/hello/build$" \ + "^CRAFT_PART_BUILD_WORK=/root/parts/hello/build$" \ + "^CRAFT_PART_INSTALL=/root/parts/hello/install$" \ + "^CRAFT_OVERLAY=/root/overlay/overlay$" \ + "^CRAFT_STAGE=/root/stage$" \ + "^CRAFT_PRIME=/root/prime$"; do + grep -q "$exp" < "$file" + done + } + + if [ "$SPREAD_SYSTEM" = "ubuntu-20.04-64" ]; then + snapcraft pull + check_vars pull.txt + + snapcraft build + check_vars build.txt + + snapcraft stage + check_vars stage.txt + + snapcraft prime + check_vars prime.txt + fi diff --git a/tests/spread/core22/environment/test-variables/snap/snapcraft.yaml b/tests/spread/core22/environment/test-variables/snap/snapcraft.yaml new file mode 100644 index 0000000000..6bca17edfa --- /dev/null +++ b/tests/spread/core22/environment/test-variables/snap/snapcraft.yaml @@ -0,0 +1,19 @@ +name: variables +version: "1" +summary: test +description: test +grade: devel +confinement: strict +base: core22 + +parts: + hello: + plugin: nil + override-pull: | + env > $CRAFT_PROJECT_DIR/pull.txt + override-build: | + env > $CRAFT_PROJECT_DIR/build.txt + override-stage: | + env > $CRAFT_PROJECT_DIR/stage.txt + override-prime: | + env > $CRAFT_PROJECT_DIR/prime.txt diff --git a/tests/spread/core22/package-repositories/task.yaml b/tests/spread/core22/package-repositories/task.yaml new file mode 100644 index 0000000000..376530f079 --- /dev/null +++ b/tests/spread/core22/package-repositories/task.yaml @@ -0,0 +1,35 @@ +summary: Test various package-repository configurations on core22 + +environment: + SNAP/test_apt_key_fingerprint: test-apt-key-fingerprint + SNAP/test_apt_key_name: test-apt-key-name + SNAP/test_apt_keyserver: test-apt-keyserver + SNAP/test_apt_ppa: test-apt-ppa + SNAPCRAFT_BUILD_ENVIRONMENT: "" + +restore: | + cd "$SNAP" + rm -f ./*.snap + snapcraft clean + snapcraft --destructive-mode + +execute: | + cd "$SNAP" + + # No jammy for this ppa yet + if [ "$(basename "$SNAP")" != "test-apt-ppa" ]; then + # Build what we have. + snapcraft --verbose --use-lxd + + # And verify the snap runs as expected. + snap install "${SNAP}"_1.0_*.snap --dangerous + snap_executable="${SNAP}.test-ppa" + [ "$("${snap_executable}")" = "hello!" ] + fi + + # Do it again in destructive mode + snap remove "${SNAP}" + snapcraft --verbose --destructive-mode + snap install "${SNAP}"_1.0_*.snap --dangerous + snap_executable="${SNAP}.test-ppa" + [ "$("${snap_executable}")" = "hello!" ] diff --git a/tests/spread/core22/package-repositories/test-apt-key-fingerprint/snap/keys/FC42E99D.asc b/tests/spread/core22/package-repositories/test-apt-key-fingerprint/snap/keys/FC42E99D.asc new file mode 100644 index 0000000000..f1976277b3 --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-key-fingerprint/snap/keys/FC42E99D.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBFRt70cBEADH/8JgKzFnwQQqtllZ3nqxYQ1cZguLCbyu9s1AwRDNu0P2oWOR +UN9YoUS15kuWtTuneVlLbdbda3N/S/HApvOWu7Q1oIrRRkpO4Jv4xN+1KaSpaTy1 +vG+HepH1D0tCSV0dmbX0S07yd0Ml7o4gMx2svBXeX41RHzjwCNkMUQJGuMF/w0hC +/Wqz6Sbki6QcqQx+YAjwVyUU1KdDRlm9efelQOskDwdr1j9Vk6ky8q+p29dEX5q2 +FApKnwJb7YPwgRDMT/kCMJzHpLxW9Zj0OLkY4epADRi+eNiMblJsWRULs5l7T5oj +yEaXFrGHzOi2HaxidUTUUro2Mb0qZUXRYoEnZV0ntmFxUPIS75sFapJdRbLF0mqy +aMFe9PtmKyFOJXC/MfMaqhMxChWRZm0f8d12zDcVe5LTnVgZaeYr+vPnhqRaDI7w +WZBtCdeMGd4BLa1b3fwY0id2Ti6egFbJzVu2v4GGojBTRkZmlw+Srdzm3w9FA/oj +mAQV/R7snK6bc2o9gtIvPGlZceUTSOtySwlOBCd50YpL2K4GdT1GlEm/DAPSPAWP +Zn9gtZOe8XLxyWd2Qca/NTU0sYeG5xdQGes7pdHz9Mqb0vN14ojE8VdqS8qZx74v +qhnN3+xJ7BDNOjAjjhOAcn1mulX4N9u/WlUw7O67Ht5V/8ODwVTh2L3lLQARAQAB +zSNMYXVuY2hwYWQgUFBBIGZvciBTbmFwcHkgRGV2ZWxvcGVyc8LBeAQTAQIAIgUC +VG3vRwIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ8YMd2vxC6Z2y1RAA +w7jFWZomYHUkUmm0FNEeRko6kv5iDNGqQXpp0JaZz06kC3dW7vjE3kNgwmgMdcA+ +/a+Jgf3ii8AHyplUQXuopHAXvZyz6YS6r17B2TuKt47MtMkWSk56UZ6av0VnE1Ms +yf6FeBEtQwojLW7ZHNZPq0BlwcvK3/H+qNHitDaIdCmCDDu9mwuerd0ZoNwbW0A1 +RPPl+Jw3uJ+tZWBAkJV+5dGzT/FJlCL28NjywktGjduhGE2nM5Q/Kd0S+kovwf9q +wmPMF8BLwUwshZoHKjLmalu08DzoyO6Bfcl6SThlO1iHoSayFnP6hJZeWkTaF/L+ +Uzbbfnjz+fWAutUoZSxHsK50VfykqgUiG9t7Kv4q5B/3s7X42O4270yEc4OSZM+Y +Ij3EOKWCgHkR3YH9/wk3w1jPiVKjO+jfZnX7FV77vVxbsR/+ibzEPEo51nWcp64q +bBf+bSSGotGv5ef6ETWw4k0cOF9Dws/zmLs9g9CYpuv5DG5d/pvSUKVmqcb2iEc2 +bymJDuKD3kE9MNCqdtnCbwVUpyRauzKhjzY8vmYlFzhlJB5WU0tR6VMMQZNcmXst +1T/RVTcIlXZUYfgbUwvPX6SOLERX1do9vtbD+XvWAYQ/J7G4knHRtf5RpiW1xQkp +FSbrQ9ACQFlqN49Ogbl47J6TZ7BrjDpROote55ixmrU= +=PEEJ +-----END PGP PUBLIC KEY BLOCK----- + diff --git a/tests/spread/core22/package-repositories/test-apt-key-fingerprint/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-apt-key-fingerprint/snap/snapcraft.yaml new file mode 100644 index 0000000000..cc90a644a0 --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-key-fingerprint/snap/snapcraft.yaml @@ -0,0 +1,25 @@ +name: test-apt-key-fingerprint +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + stage-packages: + - test-ppa + +apps: + test-ppa: + command: usr/bin/test-ppa + +package-repositories: + - type: apt + formats: [deb, deb-src] + components: [main] + suites: [focal] + key-id: 78E1918602959B9C59103100F1831DDAFC42E99D + url: http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu diff --git a/tests/spread/core22/package-repositories/test-apt-key-name/snap/keys/FC42E99D.asc b/tests/spread/core22/package-repositories/test-apt-key-name/snap/keys/FC42E99D.asc new file mode 100644 index 0000000000..f1976277b3 --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-key-name/snap/keys/FC42E99D.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +xsFNBFRt70cBEADH/8JgKzFnwQQqtllZ3nqxYQ1cZguLCbyu9s1AwRDNu0P2oWOR +UN9YoUS15kuWtTuneVlLbdbda3N/S/HApvOWu7Q1oIrRRkpO4Jv4xN+1KaSpaTy1 +vG+HepH1D0tCSV0dmbX0S07yd0Ml7o4gMx2svBXeX41RHzjwCNkMUQJGuMF/w0hC +/Wqz6Sbki6QcqQx+YAjwVyUU1KdDRlm9efelQOskDwdr1j9Vk6ky8q+p29dEX5q2 +FApKnwJb7YPwgRDMT/kCMJzHpLxW9Zj0OLkY4epADRi+eNiMblJsWRULs5l7T5oj +yEaXFrGHzOi2HaxidUTUUro2Mb0qZUXRYoEnZV0ntmFxUPIS75sFapJdRbLF0mqy +aMFe9PtmKyFOJXC/MfMaqhMxChWRZm0f8d12zDcVe5LTnVgZaeYr+vPnhqRaDI7w +WZBtCdeMGd4BLa1b3fwY0id2Ti6egFbJzVu2v4GGojBTRkZmlw+Srdzm3w9FA/oj +mAQV/R7snK6bc2o9gtIvPGlZceUTSOtySwlOBCd50YpL2K4GdT1GlEm/DAPSPAWP +Zn9gtZOe8XLxyWd2Qca/NTU0sYeG5xdQGes7pdHz9Mqb0vN14ojE8VdqS8qZx74v +qhnN3+xJ7BDNOjAjjhOAcn1mulX4N9u/WlUw7O67Ht5V/8ODwVTh2L3lLQARAQAB +zSNMYXVuY2hwYWQgUFBBIGZvciBTbmFwcHkgRGV2ZWxvcGVyc8LBeAQTAQIAIgUC +VG3vRwIbAwYLCQgHAwIGFQgCCQoLBBYCAwECHgECF4AACgkQ8YMd2vxC6Z2y1RAA +w7jFWZomYHUkUmm0FNEeRko6kv5iDNGqQXpp0JaZz06kC3dW7vjE3kNgwmgMdcA+ +/a+Jgf3ii8AHyplUQXuopHAXvZyz6YS6r17B2TuKt47MtMkWSk56UZ6av0VnE1Ms +yf6FeBEtQwojLW7ZHNZPq0BlwcvK3/H+qNHitDaIdCmCDDu9mwuerd0ZoNwbW0A1 +RPPl+Jw3uJ+tZWBAkJV+5dGzT/FJlCL28NjywktGjduhGE2nM5Q/Kd0S+kovwf9q +wmPMF8BLwUwshZoHKjLmalu08DzoyO6Bfcl6SThlO1iHoSayFnP6hJZeWkTaF/L+ +Uzbbfnjz+fWAutUoZSxHsK50VfykqgUiG9t7Kv4q5B/3s7X42O4270yEc4OSZM+Y +Ij3EOKWCgHkR3YH9/wk3w1jPiVKjO+jfZnX7FV77vVxbsR/+ibzEPEo51nWcp64q +bBf+bSSGotGv5ef6ETWw4k0cOF9Dws/zmLs9g9CYpuv5DG5d/pvSUKVmqcb2iEc2 +bymJDuKD3kE9MNCqdtnCbwVUpyRauzKhjzY8vmYlFzhlJB5WU0tR6VMMQZNcmXst +1T/RVTcIlXZUYfgbUwvPX6SOLERX1do9vtbD+XvWAYQ/J7G4knHRtf5RpiW1xQkp +FSbrQ9ACQFlqN49Ogbl47J6TZ7BrjDpROote55ixmrU= +=PEEJ +-----END PGP PUBLIC KEY BLOCK----- + diff --git a/tests/spread/core22/package-repositories/test-apt-key-name/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-apt-key-name/snap/snapcraft.yaml new file mode 100644 index 0000000000..9d65650e1f --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-key-name/snap/snapcraft.yaml @@ -0,0 +1,25 @@ +name: test-apt-key-name +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + stage-packages: + - test-ppa + +apps: + test-ppa: + command: usr/bin/test-ppa + +package-repositories: + - type: apt + formats: [deb, deb-src] + components: [main] + suites: [focal] + key-id: 78E1918602959B9C59103100F1831DDAFC42E99D + url: http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu diff --git a/tests/spread/core22/package-repositories/test-apt-keyserver/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-apt-keyserver/snap/snapcraft.yaml new file mode 100644 index 0000000000..65ace50d40 --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-keyserver/snap/snapcraft.yaml @@ -0,0 +1,25 @@ +name: test-apt-keyserver +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + stage-packages: + - test-ppa + +apps: + test-ppa: + command: usr/bin/test-ppa + +package-repositories: + - type: apt + formats: [deb, deb-src] + components: [main] + suites: [focal] + key-id: 78E1918602959B9C59103100F1831DDAFC42E99D + url: http://ppa.launchpad.net/snappy-dev/snapcraft-daily/ubuntu diff --git a/tests/spread/core22/package-repositories/test-apt-path/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-apt-path/snap/snapcraft.yaml new file mode 100644 index 0000000000..f82ce23637 --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-path/snap/snapcraft.yaml @@ -0,0 +1,19 @@ +name: test-apt-ppa +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + stage-packages: + - datacenter-gpu-manager + +package-repositories: + - type: apt + url: http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64 + key-id: AE09FE4BBD223A84B2CCFCE3F60F4B3D7FA2AF80 + path: / diff --git a/tests/spread/core22/package-repositories/test-apt-ppa/snap/snapcraft.yaml b/tests/spread/core22/package-repositories/test-apt-ppa/snap/snapcraft.yaml new file mode 100644 index 0000000000..1f12cfdb09 --- /dev/null +++ b/tests/spread/core22/package-repositories/test-apt-ppa/snap/snapcraft.yaml @@ -0,0 +1,21 @@ +name: test-apt-ppa +version: '1.0' +summary: test +description: test +grade: stable +confinement: strict +base: core22 + +parts: + test-ppa: + plugin: nil + stage-packages: + - test-ppa + +apps: + test-ppa: + command: usr/bin/test-ppa + +package-repositories: + - type: apt + ppa: snappy-dev/snapcraft-daily diff --git a/tests/spread/core22/packing/snap/snapcraft.yaml b/tests/spread/core22/packing/snap/snapcraft.yaml new file mode 100644 index 0000000000..3222b1c8e5 --- /dev/null +++ b/tests/spread/core22/packing/snap/snapcraft.yaml @@ -0,0 +1,16 @@ +name: my-snap-name +version: '0.1' +summary: Single-line elevator pitch for your amazing snap # 79 char long summary +description: | + This is my-snap's description. You have a paragraph or two to tell the + most important story about your snap. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the snap + store. +base: core22 + +grade: devel +confinement: devmode + +parts: + my-part: + plugin: nil diff --git a/tests/spread/core22/packing/task.yaml b/tests/spread/core22/packing/task.yaml new file mode 100644 index 0000000000..00dcac93df --- /dev/null +++ b/tests/spread/core22/packing/task.yaml @@ -0,0 +1,38 @@ +summary: Validate scriptlet failures + +environment: + CMD/pack: pack + CMD/pack_output: pack -o output.snap + CMD/pack_output_subdir: pack --output subdir/output.snap + CMD/snap: snap + CMD/snap_output: snap --output output.snap + CMD/snap_output_subdir: snap -o subdir/output.snap + CMD/default: "" + CMD/default_output: -o output.snap + CMD/default_output_subdir: --output subdir/output.snap + +prepare: | + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + # set_base "$SNAP_DIR/snap/snapcraft.yaml" + snap install core22 --edge + +restore: | + snapcraft clean + rm -Rf subdir ./*.snap + + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml "snap/snapcraft.yaml" + +execute: | + # shellcheck disable=SC2086 + snapcraft $CMD + + if echo "$CMD" | grep subdir/output; then + test -f subdir/output.snap + elif echo "$CMD" | grep output; then + test -f output.snap + else + test -f my-snap-name_0.1_*.snap + fi diff --git a/tests/spread/core22/scriptlets/scriptlet-failures/snap/snapcraft.yaml b/tests/spread/core22/scriptlets/scriptlet-failures/snap/snapcraft.yaml new file mode 100644 index 0000000000..bec55a0e4d --- /dev/null +++ b/tests/spread/core22/scriptlets/scriptlet-failures/snap/snapcraft.yaml @@ -0,0 +1,17 @@ +name: craftctl-build-failure +base: core22 +version: '0.1' +summary: Fail on snapcraftctl build +description: | + Failing with purpose. + +grade: devel +confinement: strict + +parts: + my-part: + plugin: make + source: . + override-build: | + craftctl set version && echo "should have failed set version" + craftctl default && echo "should have failed build" diff --git a/tests/spread/core22/scriptlets/task.yaml b/tests/spread/core22/scriptlets/task.yaml new file mode 100644 index 0000000000..13c8b4fc33 --- /dev/null +++ b/tests/spread/core22/scriptlets/task.yaml @@ -0,0 +1,28 @@ +summary: Validate scriptlet failures + +environment: + SNAP_DIR/scriptlet_failures: scriptlet-failures + +prepare: | + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + # set_base "$SNAP_DIR/snap/snapcraft.yaml" + snap install core22 --edge + +restore: | + cd "$SNAP_DIR" + snapcraft clean + rm -f ./*.snap + + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml "snap/snapcraft.yaml" + +execute: | + if [ "$SPREAD_SYSTEM" = "ubuntu-20.04-64" ]; then + cd "$SNAP_DIR" + snapcraft_log="$(snapcraft build 2>&1 || true)" + + echo "${snapcraft_log}" | NOMATCH "^should have failed set-version" + echo "${snapcraft_log}" | NOMATCH "^should have failed build" + fi diff --git a/tests/spread/general/classic-patchelf/task.yaml b/tests/spread/general/classic-patchelf/task.yaml index f66ed3e39a..19f77df958 100644 --- a/tests/spread/general/classic-patchelf/task.yaml +++ b/tests/spread/general/classic-patchelf/task.yaml @@ -1,5 +1,15 @@ summary: Build a classic snap and validates elf patching +# TODO patchelf'ing not supported in 22.04 +systems: + - -ubuntu-22.04 + - -ubuntu-22.04-64 + - -ubuntu-22.04-amd64 + - -ubuntu-22.04-arm64 + - -ubuntu-22.04-armhf + - -ubuntu-22.04-s390x + - -ubuntu-22.04-ppc64el + environment: SNAP_DIR: ../snaps/classic-patchelf diff --git a/tests/spread/general/content-interface-provider-not-found/task.yaml b/tests/spread/general/content-interface-provider-not-found/task.yaml index 5f3c9e6e6d..c758c91c3c 100644 --- a/tests/spread/general/content-interface-provider-not-found/task.yaml +++ b/tests/spread/general/content-interface-provider-not-found/task.yaml @@ -19,6 +19,6 @@ restore: | execute: | cd "$SNAP_DIR" - output=$(snapcraft prime 2>&1 >/dev/null) + output=$(snapcraft prime 2>&1 >/dev/null || true) - echo "$output" | MATCH "Could not install snap defined in plug" + echo "$output" | grep -q -e "Could not install snap defined in plug" -e "Failed to install or refresh snap 'unknown-content-snap'" diff --git a/tests/spread/general/package-repositories/task.yaml b/tests/spread/general/package-repositories/task.yaml index 307ddb8267..3b82bafa43 100644 --- a/tests/spread/general/package-repositories/task.yaml +++ b/tests/spread/general/package-repositories/task.yaml @@ -17,7 +17,7 @@ restore: | if [ "$SPREAD_SYSTEM" = "ubuntu-16.04-64" ] || [ "$SPREAD_SYSTEM" = "ubuntu-18.04-64" ] || [ "$SPREAD_SYSTEM" = "ubuntu-20.04-64" ]; then snapcraft clean --use-lxd else - snapcraft --destructive-mode + snapcraft clean --destructive-mode fi rm -f ./*.snap diff --git a/tests/spread/general/sources/snaps/git-submodules/snapcraft.yaml b/tests/spread/general/sources/snaps/git-submodules/snapcraft.yaml index 6008ec95d0..c5c42bdc6d 100644 --- a/tests/spread/general/sources/snaps/git-submodules/snapcraft.yaml +++ b/tests/spread/general/sources/snaps/git-submodules/snapcraft.yaml @@ -1,4 +1,4 @@ -name: git-submodules +name: git-recurse-submodules base: core20 version: "0.1" summary: Test the use of source-submodules @@ -8,7 +8,7 @@ confinement: strict parts: git: plugin: dump - source: https://github.com/snapcore/core20 + source: https://github.com/snapcore/core18 source-type: git source-submodules: - submodule_1 diff --git a/tests/spread/general/sources/task.yaml b/tests/spread/general/sources/task.yaml index 5e0dc3423c..d8be5df4cd 100644 --- a/tests/spread/general/sources/task.yaml +++ b/tests/spread/general/sources/task.yaml @@ -1,5 +1,15 @@ summary: Test pulling different source types +# These are tested on craft-parts for 22.04 and not all of them are available. +systems: + - -ubuntu-22.04 + - -ubuntu-22.04-64 + - -ubuntu-22.04-amd64 + - -ubuntu-22.04-arm64 + - -ubuntu-22.04-armhf + - -ubuntu-22.04-s390x + - -ubuntu-22.04-ppc64el + environment: SNAP_DIR/7z: snaps/7z SNAP_DIR/bzr_commit: snaps/bzr-commit diff --git a/tests/spread/general/store/task.yaml b/tests/spread/general/store/task.yaml index c1b16bc157..5498c31b48 100644 --- a/tests/spread/general/store/task.yaml +++ b/tests/spread/general/store/task.yaml @@ -4,14 +4,19 @@ manual: true environment: SNAP: dump-hello - SNAP_STORE_MACAROON/UBUNTU_ONE: "$(HOST: echo ${SNAP_STORE_MACAROON})" - SNAP_STORE_MACAROON/CANDID: "$(HOST: echo ${SNAP_STORE_CANDID_MACAROON})" - STORE_DASHBOARD_URL: https://dashboard.staging.snapcraft.io/ - STORE_API_URL: https://api.staging.snapcraft.io/ - STORE_UPLOAD_URL: https://upload.apps.staging.ubuntu.com/ - UBUNTU_ONE_SSO_URL: https://login.staging.ubuntu.com/ + SNAPCRAFT_STORE_CREDENTIALS/ubuntu_one: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING})" + SNAPCRAFT_STORE_CREDENTIALS/candid: "$(HOST: echo ${SNAPCRAFT_STORE_CREDENTIALS_STAGING_CANDID})" + STORE_DASHBOARD_URL: https://dashboard.staging.snapcraft.io + STORE_API_URL: https://api.staging.snapcraft.io + STORE_UPLOAD_URL: https://storage.staging.snapcraftcontent.com + UBUNTU_ONE_SSO_URL: https://login.staging.ubuntu.com prepare: | + if [[ -z "$SNAPCRAFT_STORE_CREDENTIALS" ]]; then + echo "No credentials set in env SNAPCRAFT_STORE_CREDENTIALS" + exit 1 + fi + # Install the review tools to make sure we do not break anything # assumed in there. # TODO: requires running inside $HOME. @@ -47,15 +52,8 @@ execute: | snap_file=$(ls ./*.snap) snap_name=$(grep "name: " snap/snapcraft.yaml | sed -e "s/name: \(.*$\)/\1/") - # Login - set +x - echo "${SNAP_STORE_MACAROON}" > login - set -x - if [ "${SPREAD_VARIANT}" = "CANDID" ]; then - snapcraft login --experimental-login --with login - else - snapcraft login --with login - fi + # Login mechanism + export SNAPCRAFT_STORE_AUTH="${SPREAD_VARIANT}" # Who Am I? snapcraft whoami @@ -76,7 +74,7 @@ execute: | snapcraft release "${snap_name}" 1 edge # Progressive Release - snapcraft release --experimental-progressive-releases --progressive 50 "${snap_name}" 1 candidate + snapcraft release --progressive 50 "${snap_name}" 1 candidate # Close channel snapcraft close "${snap_name}" candidate @@ -87,6 +85,3 @@ execute: | # Show metrics for a snap that we have registered in the past (empty metrics as no users!). snapcraft metrics fun --format json --name installed_base_by_operating_system snapcraft metrics fun --format table --name installed_base_by_operating_system - - # Logout - snapcraft logout diff --git a/tests/spread/general/strict-patchelf/task.yaml b/tests/spread/general/strict-patchelf/task.yaml index 3aea053a90..6f81b8cf1d 100644 --- a/tests/spread/general/strict-patchelf/task.yaml +++ b/tests/spread/general/strict-patchelf/task.yaml @@ -1,5 +1,15 @@ summary: Build a strict snap and validate elf patching +# TODO patchelf'ing not supported in 22.04 +systems: + - -ubuntu-22.04 + - -ubuntu-22.04-64 + - -ubuntu-22.04-amd64 + - -ubuntu-22.04-arm64 + - -ubuntu-22.04-armhf + - -ubuntu-22.04-s390x + - -ubuntu-22.04-ppc64el + environment: SNAP_DIR: ../snaps/strict-patchelf diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/conda-hello/hello b/tests/spread/plugins/craft-parts/build-and-run-hello/conda-hello/hello new file mode 100755 index 0000000000..15525d46ed --- /dev/null +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/conda-hello/hello @@ -0,0 +1,4 @@ +#!/usr/bin/env ipython3 + +print('hello world') + diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/conda-hello/snap/snapcraft.yaml b/tests/spread/plugins/craft-parts/build-and-run-hello/conda-hello/snap/snapcraft.yaml new file mode 100644 index 0000000000..20254a51f5 --- /dev/null +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/conda-hello/snap/snapcraft.yaml @@ -0,0 +1,25 @@ +name: conda-hello +version: '1.0' +summary: Hello world using ipython from conda packages +description: | + Leverage conda-packages to install ipython and use it to say "hello world". + +grade: devel +base: core22 +confinement: strict + +apps: + conda-hello: + command: + hello + +parts: + ipython: + plugin: conda + conda-miniconda-version: 4.6.14 + conda-packages: + - ipython + conda-python-version: "3.9" + hello: + plugin: dump + source: . diff --git a/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml b/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml new file mode 100644 index 0000000000..9a681c1ec7 --- /dev/null +++ b/tests/spread/plugins/craft-parts/build-and-run-hello/task.yaml @@ -0,0 +1,71 @@ +summary: >- + Build, clean, build, modify and rebuild, and run hello + with different plugin configurations + +environment: + SNAP/conda: conda-hello + +prepare: | + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + set_base "${SNAP}/snap/snapcraft.yaml" + +restore: | + cd "${SNAP}" + snapcraft clean + rm -f ./*.snap + + # Undo changes to hello + [ -f hello ] && git checkout hello + [ -f hello.c ] && git checkout hello.c + [ -f subdir/hello.c ] && git checkout subdir/hello.c + [ -f hello.js ] && git checkout hello.js + [ -f main.go ] && git checkout main.go + [ -f src/hello.cpp ] && git checkout src/hello.cpp + [ -f src/main.rs ] && git checkout src/main.rs + [ -f lib/src/lib.rs ] && git checkout lib/src/lib.rs + + #shellcheck source=tests/spread/tools/snapcraft-yaml.sh + . "$TOOLS_DIR/snapcraft-yaml.sh" + restore_yaml "snap/snapcraft.yaml" + +execute: | + cd "${SNAP}" + + # Build what we have and verify the snap runs as expected. + snapcraft + snap install "${SNAP}"_1.0_*.snap --dangerous + [ "$($SNAP)" = "hello world" ] + + # Clean the hello part, then build and run again. + snapcraft clean hello + snapcraft + snap install "${SNAP}"_1.0_*.snap --dangerous + [ "$($SNAP)" = "hello world" ] + + # Make sure that what we built runs with the changes applied. + if [ -f hello ]; then + modified_file=hello + elif [ -f hello.c ]; then + modified_file=hello.c + elif [ -f subdir/hello.c ]; then + modified_file=subdir/hello.c + elif [ -f hello.js ]; then + modified_file=hello.js + elif [ -f main.go ]; then + modified_file=main.go + elif [ -f src/hello.cpp ]; then + modified_file=src/hello.cpp + elif [ -f src/main.rs ]; then + modified_file=src/main.rs + elif [ -f say/src/lib.rs ]; then + modified_file=say/src/lib.rs + else + FATAL "Cannot setup ${SNAP} for rebuilding" + fi + + sed -i "${modified_file}" -e 's/hello world/hello rebuilt world/' + + snapcraft + snap install "${SNAP}"_1.0_*.snap --dangerous + [ "$($SNAP)" = "hello rebuilt world" ] diff --git a/tests/spread/plugins/v1/x-local/snaps/from-baseplugin/snap/plugins/x_local_plugin.py b/tests/spread/plugins/v1/x-local/snaps/from-baseplugin/snap/plugins/x_local_plugin.py index d3d58bde73..1ac67e0472 100644 --- a/tests/spread/plugins/v1/x-local/snaps/from-baseplugin/snap/plugins/x_local_plugin.py +++ b/tests/spread/plugins/v1/x-local/snaps/from-baseplugin/snap/plugins/x_local_plugin.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft import BasePlugin +from snapcraft_legacy import BasePlugin class LocalPlugin(BasePlugin): diff --git a/tests/spread/plugins/v1/x-local/snaps/from-nilplugin/snap/plugins/x_local_plugin.py b/tests/spread/plugins/v1/x-local/snaps/from-nilplugin/snap/plugins/x_local_plugin.py index fe965e13f0..0da5cb7461 100644 --- a/tests/spread/plugins/v1/x-local/snaps/from-nilplugin/snap/plugins/x_local_plugin.py +++ b/tests/spread/plugins/v1/x-local/snaps/from-nilplugin/snap/plugins/x_local_plugin.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.plugins.v1 import NilPlugin +from snapcraft_legacy.plugins.v1 import NilPlugin class LocalPlugin(NilPlugin): diff --git a/tests/spread/plugins/v1/x-local/snaps/from-pluginv1/snap/plugins/x_local_plugin.py b/tests/spread/plugins/v1/x-local/snaps/from-pluginv1/snap/plugins/x_local_plugin.py index ddb62be586..d92443122b 100644 --- a/tests/spread/plugins/v1/x-local/snaps/from-pluginv1/snap/plugins/x_local_plugin.py +++ b/tests/spread/plugins/v1/x-local/snaps/from-pluginv1/snap/plugins/x_local_plugin.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft.plugins.v1 import PluginV1 +from snapcraft_legacy.plugins.v1 import PluginV1 class LocalPlugin(PluginV1): diff --git a/tests/spread/plugins/v1/x-local/snaps/x-compat-name/snap/plugins/x-local-plugin.py b/tests/spread/plugins/v1/x-local/snaps/x-compat-name/snap/plugins/x-local-plugin.py index d3d58bde73..1ac67e0472 100644 --- a/tests/spread/plugins/v1/x-local/snaps/x-compat-name/snap/plugins/x-local-plugin.py +++ b/tests/spread/plugins/v1/x-local/snaps/x-compat-name/snap/plugins/x-local-plugin.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from snapcraft import BasePlugin +from snapcraft_legacy import BasePlugin class LocalPlugin(BasePlugin): diff --git a/tests/spread/plugins/v2/snaps/local-plugin-from-base-hello/snap/plugins/x_local_plugin.py b/tests/spread/plugins/v2/snaps/local-plugin-from-base-hello/snap/plugins/x_local_plugin.py index 9fe6ad7e73..955add8cc2 100644 --- a/tests/spread/plugins/v2/snaps/local-plugin-from-base-hello/snap/plugins/x_local_plugin.py +++ b/tests/spread/plugins/v2/snaps/local-plugin-from-base-hello/snap/plugins/x_local_plugin.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List, Set -from snapcraft.plugins.v2 import PluginV2 +from snapcraft_legacy.plugins.v2 import PluginV2 class PluginImpl(PluginV2): diff --git a/tests/spread/plugins/v2/snaps/local-plugin-from-nil-hello/snap/plugins/x_local_plugin.py b/tests/spread/plugins/v2/snaps/local-plugin-from-nil-hello/snap/plugins/x_local_plugin.py index b4f8e84f2a..d2d37f78f4 100644 --- a/tests/spread/plugins/v2/snaps/local-plugin-from-nil-hello/snap/plugins/x_local_plugin.py +++ b/tests/spread/plugins/v2/snaps/local-plugin-from-nil-hello/snap/plugins/x_local_plugin.py @@ -16,7 +16,7 @@ from typing import List, Set -from snapcraft.plugins.v2 import nil +from snapcraft_legacy.plugins.v2 import nil class PluginImpl(nil.NilPlugin): diff --git a/tests/spread/tools/restore.sh b/tests/spread/tools/restore.sh index 1211a8ab65..2c5f80e551 100755 --- a/tests/spread/tools/restore.sh +++ b/tests/spread/tools/restore.sh @@ -13,7 +13,7 @@ apt-get autoremove --purge -y snaps="$(snap list | awk '{if (NR!=1) {print $1}}')" for snap in $snaps; do case "$snap" in - "bare" | "core" | "core18" | "core20" | "snapcraft" | "multipass" | "lxd" | "snapd") + "bare" | "core" | "core18" | "core20" | "core22" | "snapcraft" | "multipass" | "lxd" | "snapd") # Do not or cannot remove these ;; *) diff --git a/tests/spread/tools/snapcraft-yaml.sh b/tests/spread/tools/snapcraft-yaml.sh index 0f4be5fdb4..ed4912feea 100755 --- a/tests/spread/tools/snapcraft-yaml.sh +++ b/tests/spread/tools/snapcraft-yaml.sh @@ -2,7 +2,9 @@ get_base() { - if [[ "$SPREAD_SYSTEM" =~ ubuntu-20.04 ]]; then + if [[ "$SPREAD_SYSTEM" =~ ubuntu-22.04 ]]; then + echo "core22" + elif [[ "$SPREAD_SYSTEM" =~ ubuntu-20.04 ]]; then echo "core20" elif [[ "$SPREAD_SYSTEM" =~ ubuntu-18.04 ]]; then echo "core18" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index b201ad1278..e69de29bb2 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,320 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2015-2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import http.server -import logging -import os -import stat -import threading -from unittest import mock - -import apt -import fixtures -import progressbar -import testscenarios -import testtools - -from snapcraft.internal import common, steps -from tests import fake_servers, fixture_setup -from tests.file_utils import get_snapcraft_path -from tests.unit.part_loader import load_part - - -class ContainsList(list): - def __eq__(self, other): - return all([i[0] in i[1] for i in zip(self, other)]) - - -class MockOptions: - def __init__( - self, - source=None, - source_type=None, - source_branch=None, - source_tag=None, - source_subdir=None, - source_depth=None, - source_commit=None, - source_checksum=None, - source_submodules=None, - disable_parallel=False, - ): - self.source = source - self.source_type = source_type - self.source_depth = source_depth - self.source_branch = source_branch - self.source_commit = source_commit - self.source_tag = source_tag - self.source_subdir = source_subdir - self.source_submodules = source_submodules - self.disable_parallel = disable_parallel - - -class IsExecutable: - """Match if a file path is executable.""" - - def __str__(self): - return "IsExecutable()" - - def match(self, file_path): - if not os.stat(file_path).st_mode & stat.S_IEXEC: - return testtools.matchers.Mismatch( - "Expected {!r} to be executable, but it was not".format(file_path) - ) - return None - - -class LinkExists: - """Match if a file path is a symlink.""" - - def __init__(self, expected_target=None): - self._expected_target = expected_target - - def __str__(self): - return "LinkExists()" - - def match(self, file_path): - if not os.path.exists(file_path): - return testtools.matchers.Mismatch( - "Expected {!r} to be a symlink, but it doesn't exist".format(file_path) - ) - - if not os.path.islink(file_path): - return testtools.matchers.Mismatch( - "Expected {!r} to be a symlink, but it was not".format(file_path) - ) - - target = os.readlink(file_path) - if target != self._expected_target: - return testtools.matchers.Mismatch( - "Expected {!r} to be a symlink pointing to {!r}, but it was " - "pointing to {!r}".format(file_path, self._expected_target, target) - ) - - return None - - -class TestCase(testscenarios.WithScenarios, testtools.TestCase): - @classmethod - def setUpClass(cls): - cls.fake_snapd = fixture_setup.FakeSnapd() - cls.fake_snapd.setUp() - - @classmethod - def tearDownClass(cls): - cls.fake_snapd.cleanUp() - - def setUp(self): - super().setUp() - temp_cwd_fixture = fixture_setup.TempCWD() - self.useFixture(temp_cwd_fixture) - self.path = temp_cwd_fixture.path - - # Use a separate path for XDG dirs, or changes there may be detected as - # source changes. - self.xdg_path = self.useFixture(fixtures.TempDir()).path - self.useFixture(fixture_setup.TempXDG(self.xdg_path)) - self.fake_terminal = fixture_setup.FakeTerminal() - self.useFixture(self.fake_terminal) - # Some tests will directly or indirectly change the plugindir, which - # is a module variable. Make sure that it is returned to the original - # value when a test ends. - self.addCleanup(common.set_plugindir, common.get_plugindir()) - self.addCleanup(common.set_schemadir, common.get_schemadir()) - self.addCleanup(common.set_extensionsdir, common.get_extensionsdir()) - self.addCleanup(common.set_keyringsdir, common.get_keyringsdir()) - self.addCleanup(common.reset_env) - common.set_schemadir(os.path.join(get_snapcraft_path(), "schema")) - self.fake_logger = fixtures.FakeLogger(level=logging.ERROR) - self.useFixture(self.fake_logger) - - # Some tests will change the apt Dir::Etc::Trusted and - # Dir::Etc::TrustedParts directories. Make sure they're properly reset. - self.addCleanup( - apt.apt_pkg.config.set, - "Dir::Etc::Trusted", - apt.apt_pkg.config.find_file("Dir::Etc::Trusted"), - ) - self.addCleanup( - apt.apt_pkg.config.set, - "Dir::Etc::TrustedParts", - apt.apt_pkg.config.find_file("Dir::Etc::TrustedParts"), - ) - - patcher = mock.patch("os.sched_getaffinity") - self.cpu_count = patcher.start() - self.cpu_count.return_value = {1, 2} - self.addCleanup(patcher.stop) - - # We do not want the paths to affect every test we have. - patcher = mock.patch( - "snapcraft.file_utils.get_snap_tool_path", side_effect=lambda x: x - ) - patcher.start() - self.addCleanup(patcher.stop) - - patcher = mock.patch( - "snapcraft.internal.indicators.ProgressBar", new=SilentProgressBar - ) - patcher.start() - self.addCleanup(patcher.stop) - - # These are what we expect by default - self.snap_dir = os.path.join(os.getcwd(), "snap") - self.prime_dir = os.path.join(os.getcwd(), "prime") - self.stage_dir = os.path.join(os.getcwd(), "stage") - self.parts_dir = os.path.join(os.getcwd(), "parts") - self.local_plugins_dir = os.path.join(self.snap_dir, "plugins") - - # Use this host to run through the lifecycle tests - self.useFixture( - fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT", "host") - ) - - # Make sure snap installation does the right thing - self.fake_snapd.installed_snaps = [ - dict(name="core20", channel="stable", revision="10"), - dict(name="core18", channel="stable", revision="10"), - ] - self.fake_snapd.snaps_result = [ - dict(name="core20", channel="stable", revision="10"), - dict(name="core18", channel="stable", revision="10"), - ] - self.fake_snapd.find_result = [ - dict( - core20=dict( - channel="stable", - channels={"latest/stable": dict(confinement="strict")}, - ) - ), - dict( - core18=dict( - channel="stable", - channels={"latest/stable": dict(confinement="strict")}, - ) - ), - ] - self.fake_snapd.snap_details_func = None - - self.fake_snap_command = fixture_setup.FakeSnapCommand() - self.useFixture(self.fake_snap_command) - - # Avoid installing patchelf in the tests - self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_NO_PATCHELF", "1")) - - # Disable Sentry reporting for tests, otherwise they'll hang waiting - # for input - self.useFixture( - fixtures.EnvironmentVariable("SNAPCRAFT_ENABLE_ERROR_REPORTING", "false") - ) - - # Don't let the managed host variable leak into tests - self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_MANAGED_HOST")) - - machine = os.environ.get("SNAPCRAFT_TEST_MOCK_MACHINE", None) - self.base_environment = fixture_setup.FakeBaseEnvironment(machine=machine) - self.useFixture(self.base_environment) - - # Make sure "SNAPCRAFT_ENABLE_DEVELOPER_DEBUG" is reset between tests - self.useFixture( - fixtures.EnvironmentVariable("SNAPCRAFT_ENABLE_DEVELOPER_DEBUG") - ) - self.useFixture(fixture_setup.FakeSnapcraftctl()) - - # Don't let host SNAPCRAFT_BUILD_INFO variable leak into tests - self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_INFO")) - - def make_snapcraft_yaml(self, content, encoding="utf-8", location=""): - snap_dir = os.path.join(location, "snap") - os.makedirs(snap_dir, exist_ok=True) - snapcraft_yaml = os.path.join(snap_dir, "snapcraft.yaml") - with open(snapcraft_yaml, "w", encoding=encoding) as fp: - fp.write(content) - return snapcraft_yaml - - def verify_state(self, part_name, state_dir, expected_step_name): - self.assertTrue( - os.path.isdir(state_dir), - "Expected state directory for {}".format(part_name), - ) - - # Expect every step up to and including the specified one to be run - step = steps.get_step_by_name(expected_step_name) - for step in step.previous_steps() + [step]: - self.assertTrue( - os.path.exists(os.path.join(state_dir, step.name)), - "Expected {!r} to be run for {}".format(step.name, part_name), - ) - - def load_part( - self, - part_name, - plugin_name=None, - part_properties=None, - project=None, - stage_packages_repo=None, - snap_name="test-snap", - base="core18", - build_base=None, - confinement="strict", - snap_type="app", - ): - return load_part( - part_name=part_name, - plugin_name=plugin_name, - part_properties=part_properties, - project=project, - stage_packages_repo=stage_packages_repo, - snap_name=snap_name, - base=base, - build_base=build_base, - confinement=confinement, - snap_type=snap_type, - ) - - -class TestWithFakeRemoteParts(TestCase): - def setUp(self): - super().setUp() - self.useFixture(fixture_setup.FakeParts()) - - -class FakeFileHTTPServerBasedTestCase(TestCase): - def setUp(self): - super().setUp() - - self.useFixture(fixtures.EnvironmentVariable("no_proxy", "localhost,127.0.0.1")) - self.server = http.server.HTTPServer( - ("127.0.0.1", 0), fake_servers.FakeFileHTTPRequestHandler - ) - server_thread = threading.Thread(target=self.server.serve_forever) - self.addCleanup(server_thread.join) - self.addCleanup(self.server.server_close) - self.addCleanup(self.server.shutdown) - server_thread.start() - - -class SilentProgressBar(progressbar.ProgressBar): - """A progress bar causing no spurious output during tests.""" - - def start(self): - pass - - def update(self, value=None): - pass - - def finish(self): - pass diff --git a/tests/unit/cli/test_default_command.py b/tests/unit/cli/test_default_command.py new file mode 100644 index 0000000000..4cc22ea845 --- /dev/null +++ b/tests/unit/cli/test_default_command.py @@ -0,0 +1,112 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +import sys +from unittest.mock import call + +import pytest + +from snapcraft import cli + + +def test_default_command(mocker): + mocker.patch.object(sys, "argv", ["cmd"]) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + debug=False, + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_default_command_destructive_mode(mocker): + mocker.patch.object(sys, "argv", ["cmd", "--destructive-mode"]) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=True, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_default_command_use_lxd(mocker): + mocker.patch.object(sys, "argv", ["cmd", "--use-lxd"]) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=False, + use_lxd=True, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +@pytest.mark.parametrize("option", ["-o", "--output"]) +def test_default_command_output(mocker, option): + mocker.patch.object(sys, "argv", ["cmd", option, "name"]) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output="name", + debug=False, + destructive_mode=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] diff --git a/tests/unit/cli/test_lifecycle.py b/tests/unit/cli/test_lifecycle.py index 1165e11afd..9f4daa2204 100644 --- a/tests/unit/cli/test_lifecycle.py +++ b/tests/unit/cli/test_lifecycle.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2020 Canonical Ltd +# Copyright 2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,45 +14,436 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from unittest import mock +import argparse +import sys +from unittest.mock import call import pytest -from snapcraft.cli import lifecycle +from snapcraft import cli @pytest.mark.parametrize( - "output,pack_name,pack_dir", + "cmd,run_method", [ - ("/tmp/output.snap", "output.snap", "/tmp"), - ("/tmp", None, "/tmp"), - ("output.snap", "output.snap", None), + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), ], ) +def test_lifecycle_command(cmd, run_method, mocker): + mocker.patch.object(sys, "argv", ["cmd", cmd]) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=[], + debug=False, + destructive_mode=False, + shell=False, + shell_after=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + @pytest.mark.parametrize( - "compression", - ["xz", "lzo", None], + "cmd,run_method", + [ + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), + ], ) -@mock.patch("snapcraft.file_utils.get_host_tool_path", return_value="/bin/snap") -@mock.patch("snapcraft.cli.lifecycle._run_pack", return_value="ignore.snap") -def test_pack(mock_run_pack, mock_host_tool, compression, output, pack_name, pack_dir): - lifecycle._pack(directory="/my/snap", compression=compression, output=output) +def test_lifecycle_command_arguments(cmd, run_method, mocker): + mocker.patch.object( + sys, + "argv", + [ + "cmd", + cmd, + "part1", + "part2", + ], + ) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=["part1", "part2"], + debug=False, + destructive_mode=False, + shell=False, + shell_after=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + - assert mock_host_tool.mock_calls == [ - mock.call(command_name="snap", package_name="snapd") +@pytest.mark.parametrize( + "cmd,run_method", + [ + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), + ], +) +def test_lifecycle_command_arguments_destructive_mode(cmd, run_method, mocker): + mocker.patch.object( + sys, + "argv", + [ + "cmd", + cmd, + "--destructive-mode", + "part1", + "part2", + ], + ) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=["part1", "part2"], + debug=False, + destructive_mode=True, + shell=False, + shell_after=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) ] - pack_command = ["/bin/snap", "pack"] - if compression: - pack_command.extend(["--compression", compression]) +@pytest.mark.parametrize( + "cmd,run_method", + [ + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), + ], +) +def test_lifecycle_command_arguments_use_lxd(cmd, run_method, mocker): + mocker.patch.object( + sys, + "argv", + [ + "cmd", + cmd, + "--use-lxd", + "part1", + "part2", + ], + ) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=["part1", "part2"], + debug=False, + destructive_mode=False, + shell=False, + shell_after=False, + use_lxd=True, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] - if pack_name: - pack_command.extend(["--filename", pack_name]) - pack_command.append("/my/snap") +@pytest.mark.parametrize( + "cmd,run_method", + [ + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), + ], +) +def test_lifecycle_command_arguments_debug(cmd, run_method, mocker): + mocker.patch.object( + sys, + "argv", + [ + "cmd", + cmd, + "--debug", + ], + ) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=[], + debug=True, + destructive_mode=False, + shell=False, + shell_after=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] - if pack_dir: - pack_command.append(pack_dir) - assert mock_run_pack.mock_calls == [mock.call(pack_command)] +@pytest.mark.parametrize( + "cmd,run_method", + [ + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), + ], +) +def test_lifecycle_command_arguments_shell(cmd, run_method, mocker): + mocker.patch.object( + sys, + "argv", + [ + "cmd", + cmd, + "--shell", + ], + ) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=[], + debug=False, + destructive_mode=False, + shell=True, + shell_after=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +@pytest.mark.parametrize( + "cmd,run_method", + [ + ("pull", "snapcraft.commands.lifecycle.PullCommand.run"), + ("build", "snapcraft.commands.lifecycle.BuildCommand.run"), + ("stage", "snapcraft.commands.lifecycle.StageCommand.run"), + ("prime", "snapcraft.commands.lifecycle.PrimeCommand.run"), + ], +) +def test_lifecycle_command_arguments_shell_after(cmd, run_method, mocker): + mocker.patch.object( + sys, + "argv", + [ + "cmd", + cmd, + "--shell-after", + ], + ) + mock_lifecycle_cmd = mocker.patch(run_method) + cli.run() + assert mock_lifecycle_cmd.mock_calls == [ + call( + argparse.Namespace( + parts=[], + debug=False, + destructive_mode=False, + shell=False, + shell_after=True, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_lifecycle_command_pack(mocker): + mocker.patch.object( + sys, + "argv", + ["cmd", "pack"], + ) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_lifecycle_command_pack_destructive_mode(mocker): + mocker.patch.object( + sys, + "argv", + ["cmd", "pack", "--destructive-mode"], + ) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=True, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_lifecycle_command_pack_use_lxd(mocker): + mocker.patch.object( + sys, + "argv", + ["cmd", "pack", "--use-lxd"], + ) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=False, + use_lxd=True, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_lifecycle_command_pack_debug(mocker): + mocker.patch.object( + sys, + "argv", + ["cmd", "pack", "--debug"], + ) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output=None, + debug=True, + destructive_mode=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +@pytest.mark.parametrize("option", ["-o", "--output"]) +def test_lifecycle_command_pack_output(mocker, option): + mocker.patch.object(sys, "argv", ["cmd", "pack", option, "name"]) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + directory=None, + output="name", + debug=False, + destructive_mode=False, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] + + +def test_lifecycle_command_pack_directory(mocker): + mocker.patch.object(sys, "argv", ["cmd", "pack", "name"]) + mock_pack_cmd = mocker.patch("snapcraft.commands.lifecycle.PackCommand.run") + cli.run() + assert mock_pack_cmd.mock_calls == [ + call( + argparse.Namespace( + debug=False, + destructive_mode=False, + directory="name", + output=None, + use_lxd=False, + enable_experimental_extensions=False, + enable_developer_debug=False, + enable_experimental_target_arch=False, + target_arch=None, + provider=None, + ) + ) + ] diff --git a/tests/unit/cli/test_version.py b/tests/unit/cli/test_version.py new file mode 100644 index 0000000000..c352240894 --- /dev/null +++ b/tests/unit/cli/test_version.py @@ -0,0 +1,40 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +import sys +from unittest.mock import call + +from snapcraft import __version__, cli + + +def test_version_command(mocker): + mocker.patch.object(sys, "argv", ["cmd", "version"]) + mock_version_cmd = mocker.patch("snapcraft.commands.version.VersionCommand.run") + cli.run() + assert mock_version_cmd.mock_calls == [call(argparse.Namespace())] + + +def test_version_argument(mocker, emitter): + mocker.patch.object(sys, "argv", ["cmd", "--version"]) + cli.run() + emitter.assert_message(f"snapcraft {__version__}") + + +def test_version_argument_with_command(mocker, emitter): + mocker.patch.object(sys, "argv", ["cmd", "--version", "version"]) + cli.run() + emitter.assert_message(f"snapcraft {__version__}") diff --git a/tests/unit/commands/__init__.py b/tests/unit/commands/__init__.py index 888c8979b0..e69de29bb2 100644 --- a/tests/unit/commands/__init__.py +++ b/tests/unit/commands/__init__.py @@ -1,463 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2015-2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import json -import subprocess -from pathlib import PosixPath -from textwrap import dedent -from unittest import mock - -import fixtures -from click.testing import CliRunner - -from snapcraft import storeapi -from snapcraft.cli._runner import run -from snapcraft.storeapi import metrics -from snapcraft.storeapi.v2.channel_map import ChannelMap -from snapcraft.storeapi.v2.releases import Releases -from tests import fixture_setup, unit - -_sample_keys = [ - { - "name": "default", - "sha3-384": "vdEeQvRxmZ26npJCFaGnl-VfGz0lU2jZZkWp_s7E-RxVCNtH2_mtjcxq2NkDKkIp", - }, - { - "name": "another", - "sha3-384": "JsfToV5hO2eN9l89pYYCKXUioTERrZIIHUgQQd47jW8YNNBskupiIjWYd3KXLY_D", - }, -] - - -def get_sample_key(name): - for key in _sample_keys: - if key["name"] == name: - return key - raise KeyError(name) - - -original_check_output = subprocess.check_output - - -def mock_check_output(command, *args, **kwargs): - if isinstance(command[0], PosixPath): - command[0] = str(command[0]) - if command[0].endswith("unsquashfs") or command[0].endswith("xdelta3"): - return original_check_output(command, *args, **kwargs) - elif command[0].endswith("snap") and command[1:] == ["keys", "--json"]: - return json.dumps(_sample_keys) - elif command[0].endswith("snap") and command[1] == "export-key": - if not command[2].startswith("--account="): - raise AssertionError("Unhandled command: {}".format(command)) - account_id = command[2][len("--account=") :] - name = command[3] - # This isn't a full account-key-request assertion, but it's enough - # for testing. - return dedent( - """\ - type: account-key-request - account-id: {account_id} - name: {name} - public-key-sha3-384: {sha3_384} - """ - ).format( - account_id=account_id, name=name, sha3_384=get_sample_key(name)["sha3-384"] - ) - elif command[0].endswith("snap") and command[1:] == [ - "create-key", - "new-key", - ]: - pass - else: - raise AssertionError("Unhandled command: {}".format(command)) - - -class CommandBaseTestCase(unit.TestCase): - def setUp(self): - super().setUp() - self.runner = CliRunner() - - def run_command(self, args, **kwargs): - # For click testing, runner will overwrite the descriptors for stdio - - # ensure TTY always appears connected. - self.useFixture( - fixtures.MockPatch("snapcraft.cli.echo.is_tty_connected", return_value=True) - ) - - with mock.patch("sys.argv", args): - return self.runner.invoke(run, args, catch_exceptions=False, **kwargs) - - -class LifecycleCommandsBaseTestCase(CommandBaseTestCase): - def setUp(self): - super().setUp() - - self.useFixture(fixtures.EnvironmentVariable("SNAPCRAFT_BUILD_ENVIRONMENT")) - - self.fake_lifecycle_clean = fixtures.MockPatch( - "snapcraft.internal.lifecycle.clean" - ) - self.useFixture(self.fake_lifecycle_clean) - - self.fake_lifecycle_execute = fixtures.MockPatch( - "snapcraft.internal.lifecycle.execute" - ) - self.useFixture(self.fake_lifecycle_execute) - - self.fake_pack = fixtures.MockPatch("snapcraft.cli.lifecycle._pack") - self.useFixture(self.fake_pack) - - self.snapcraft_yaml = fixture_setup.SnapcraftYaml( - self.path, - parts={ - "part0": {"plugin": "nil"}, - "part1": {"plugin": "nil"}, - "part2": {"plugin": "nil"}, - }, - ) - self.useFixture(self.snapcraft_yaml) - - self.provider_class_mock = mock.MagicMock() - self.provider_mock = mock.MagicMock() - self.provider_class_mock.return_value.__enter__.return_value = ( - self.provider_mock - ) - - self.fake_get_provider_for = fixtures.MockPatch( - "snapcraft.internal.build_providers.get_provider_for", - return_value=self.provider_class_mock, - ) - self.useFixture(self.fake_get_provider_for) - - def assert_clean_not_called(self): - self.fake_lifecycle_clean.mock.assert_not_called() - self.provider_mock.clean.assert_not_called() - self.provider_mock.clean_project.assert_not_called() - - -class StoreCommandsBaseTestCase(CommandBaseTestCase): - def setUp(self): - super().setUp() - self.fake_store = fixture_setup.FakeStore() - self.useFixture(self.fake_store) - self.client = storeapi.StoreClient() - - -class FakeStoreCommandsBaseTestCase(CommandBaseTestCase): - def setUp(self): - super().setUp() - - # Our experimental environment variable is sticky - self.useFixture( - fixtures.EnvironmentVariable( - "SNAPCRAFT_EXPERIMENTAL_PROGRESSIVE_RELEASES", None - ) - ) - - self.fake_store_login = fixtures.MockPatchObject(storeapi.StoreClient, "login") - self.useFixture(self.fake_store_login) - - self.fake_store_register = fixtures.MockPatchObject( - storeapi._dashboard_api.DashboardAPI, "register" - ) - self.useFixture(self.fake_store_register) - - self.fake_store_account_info_data = { - "account_id": "abcd", - "account_keys": list(), - "snaps": { - "16": { - "snap-test": { - "snap-id": "snap-test-snap-id", - "status": "Approved", - "private": False, - "since": "2016-12-12T01:01Z", - "price": "0", - }, - "basic": { - "snap-id": "basic-snap-id", - "status": "Approved", - "private": False, - "since": "2016-12-12T01:01Z", - "price": "0", - }, - } - }, - } - - self.fake_store_account_info = fixtures.MockPatchObject( - storeapi._dashboard_api.DashboardAPI, - "get_account_information", - return_value=self.fake_store_account_info_data, - ) - self.useFixture(self.fake_store_account_info) - - self.fake_store_status = fixtures.MockPatchObject( - storeapi._dashboard_api.DashboardAPI, "snap_status", return_value=dict() - ) - self.useFixture(self.fake_store_status) - - self.fake_store_release = fixtures.MockPatchObject( - storeapi.StoreClient, "release" - ) - self.useFixture(self.fake_store_release) - - self.fake_store_register_key = fixtures.MockPatchObject( - storeapi._dashboard_api.DashboardAPI, "register_key" - ) - self.useFixture(self.fake_store_register_key) - - # channel-map endpoint - self.channel_map = ChannelMap.unmarshal( - { - "channel-map": [ - { - "architecture": "amd64", - "channel": "2.1/beta", - "expiration-date": None, - "revision": 19, - "progressive": { - "paused": None, - "percentage": None, - "current-percentage": None, - }, - }, - { - "architecture": "amd64", - "channel": "2.0/beta", - "expiration-date": None, - "revision": 18, - "progressive": { - "paused": None, - "percentage": None, - "current-percentage": None, - }, - }, - ], - "revisions": [ - {"architectures": ["amd64"], "revision": 19, "version": "10"}, - {"architectures": ["amd64"], "revision": 18, "version": "10"}, - ], - "snap": { - "name": "snap-test", - "channels": [ - { - "branch": None, - "fallback": None, - "name": "2.1/stable", - "risk": "stable", - "track": "2.1", - }, - { - "branch": None, - "fallback": "2.1/stable", - "name": "2.1/candidate", - "risk": "candidate", - "track": "2.1", - }, - { - "branch": None, - "fallback": "2.1/candidate", - "name": "2.1/beta", - "risk": "beta", - "track": "2.1", - }, - { - "branch": None, - "fallback": "2.1/beta", - "name": "2.1/edge", - "risk": "edge", - "track": "2.1", - }, - { - "branch": None, - "fallback": None, - "name": "2.0/stable", - "risk": "stable", - "track": "2.0", - }, - { - "branch": None, - "fallback": "2.0/stable", - "name": "2.0/candidate", - "risk": "candidate", - "track": "2.0", - }, - { - "branch": None, - "fallback": "2.0/candidate", - "name": "2.0/beta", - "risk": "beta", - "track": "2.0", - }, - { - "branch": None, - "fallback": "2.0/beta", - "name": "2.0/edge", - "risk": "edge", - "track": "2.0", - }, - ], - "default-track": "2.1", - "tracks": [ - { - "name": "2.0", - "status": "default", - "creation-date": "2019-10-17T14:11:59Z", - "version-pattern": "2\\.*", - }, - { - "name": "latest", - "status": "active", - "creation-date": None, - "version-pattern": None, - }, - ], - }, - } - ) - self.fake_store_get_snap_channel_map = fixtures.MockPatchObject( - storeapi.StoreClient, "get_snap_channel_map", return_value=self.channel_map - ) - self.useFixture(self.fake_store_get_snap_channel_map) - - self.metrics = metrics.MetricsResults( - metrics=[ - metrics.MetricResults( - status=metrics.MetricsStatus["OK"], - snap_id="test-snap-id", - metric_name="daily_device_change", - buckets=["2021-01-01", "2021-01-02", "2021-01-03"], - series=[ - metrics.Series( - name="continued", - values=[10, 11, 12], - currently_released=None, - ), - metrics.Series( - name="lost", values=[1, 2, 3], currently_released=None - ), - metrics.Series( - name="new", values=[2, 3, 4], currently_released=None - ), - ], - ) - ] - ) - self.fake_store_get_metrics = fixtures.MockPatchObject( - storeapi.StoreClient, "get_metrics", return_value=self.metrics - ) - self.useFixture(self.fake_store_get_metrics) - - self.releases = Releases.unmarshal( - { - "revisions": [ - { - "architectures": ["i386"], - "base": "core20", - "build_url": None, - "confinement": "strict", - "created_at": " 2016-09-27T19:23:40Z", - "grade": "stable", - "revision": 2, - "sha3-384": "a9060ef4872ccacbfa440617a76fcd84967896b28d0d1eb7571f00a1098d766e7e93353b084ba6ad841d7b14b95ede48", - "size": 20, - "status": "Published", - "version": "2.0.1", - }, - { - "architectures": ["amd64"], - "base": "core20", - "build_url": None, - "confinement": "strict", - "created_at": "2016-09-27T18:38:43Z", - "grade": "stable", - "revision": 1, - "sha3-384": "a9060ef4872ccacbfa440617a76fcd84967896b28d0d1eb7571f00a1098d766e7e93353b084ba6ad841d7b14b95ede48", - "size": 20, - "status": "Published", - "version": "2.0.2", - }, - ], - "releases": [ - { - "architecture": "amd64", - "branch": None, - "channel": "latest/stable", - "expiration-date": None, - "revision": 1, - "risk": "stable", - "track": "latest", - "when": "2020-02-12T17:51:40.891996Z", - }, - { - "architecture": "i386", - "branch": None, - "channel": "latest/stable", - "expiration-date": None, - "revision": None, - "risk": "stable", - "track": "latest", - "when": "2020-02-11T17:51:40.891996Z", - }, - { - "architecture": "amd64", - "branch": None, - "channel": "latest/edge", - "expiration-date": None, - "revision": 1, - "risk": "stable", - "track": "latest", - "when": "2020-01-12T17:51:40.891996Z", - }, - ], - } - ) - self.fake_store_get_releases = fixtures.MockPatchObject( - storeapi.StoreClient, "get_snap_releases", return_value=self.releases - ) - self.useFixture(self.fake_store_get_releases) - - # Uploading - self.mock_tracker = mock.Mock(storeapi._status_tracker.StatusTracker) - self.mock_tracker.track.return_value = { - "code": "ready_to_release", - "processed": True, - "can_release": True, - "url": "/fake/url", - "revision": 19, - } - self.fake_store_upload_precheck = fixtures.MockPatchObject( - storeapi.StoreClient, "upload_precheck" - ) - self.useFixture(self.fake_store_upload_precheck) - - self.fake_store_upload = fixtures.MockPatchObject( - storeapi.StoreClient, "upload", return_value=self.mock_tracker - ) - self.useFixture(self.fake_store_upload) - - # Mock the snap command, pass through a select few. - self.fake_check_output = fixtures.MockPatch( - "subprocess.check_output", side_effect=mock_check_output - ) - self.useFixture(self.fake_check_output) - - # Pretend that the snap command is available - self.fake_package_installed = fixtures.MockPatch( - "snapcraft.internal.repo.Repo.is_package_installed", return_value=True - ) - self.useFixture(self.fake_package_installed) diff --git a/tests/unit/commands/conftest.py b/tests/unit/commands/conftest.py index 15d466ab79..77764dcce0 100644 --- a/tests/unit/commands/conftest.py +++ b/tests/unit/commands/conftest.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2017-2021 Canonical Ltd +# Copyright 2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,20 +14,21 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from typing import List import pytest -from click.testing import CliRunner - -from snapcraft.cli._runner import run @pytest.fixture -def click_run(): - """Run commands using Click's testing backend.""" - cli = CliRunner() +def fake_client(mocker): + """Forces get_client to return a fake craft_store.BaseClient""" + client = mocker.patch("craft_store.BaseClient", autospec=True) + mocker.patch("snapcraft.commands.store.client.get_client", return_value=client) + return client - def runner(args: List[str]): - return cli.invoke(run, args) - return runner +@pytest.fixture +def fake_confirmation_prompt(mocker): + """Fake the confirmation prompt.""" + return mocker.patch( + "snapcraft.utils.confirm_with_user", return_value=False, autospec=True + ) diff --git a/tests/unit/commands/store/__init__.py b/tests/unit/commands/store/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/commands/store/test_channel_map.py b/tests/unit/commands/store/test_channel_map.py new file mode 100644 index 0000000000..2914c7e61e --- /dev/null +++ b/tests/unit/commands/store/test_channel_map.py @@ -0,0 +1,386 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright (C) 2020, 2022 Canonical Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest + +from snapcraft.commands.store import channel_map + +############ +# Fixtures # +############ + + +@pytest.fixture +def channel_payload(): + return { + "name": "latest/candidate", + "track": "latest", + "risk": "candidate", + "branch": None, + "fallback": None, + } + + +@pytest.fixture +def mapped_channel_payload(): + return { + "architecture": "amd64", + "channel": "latest/stable", + "expiration-date": None, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + "revision": 2, + } + + +######### +# Tests # +######### + + +def test_progressive(): + payload = {"paused": False, "percentage": 83.3, "current-percentage": 32.1} + + p = channel_map.Progressive.unmarshal(payload) + + assert repr(p) == "83.3>" + assert p.paused == payload["paused"] + assert p.percentage == payload["percentage"] + assert p.current_percentage == payload["current-percentage"] + assert p.marshal() == payload + + +def test_none(): + payload = {"paused": None, "percentage": None, "current-percentage": None} + + p = channel_map.Progressive.unmarshal(payload) + + assert repr(p) == "None>" + assert p.paused == payload["paused"] + assert p.percentage == payload["percentage"] + assert p.current_percentage == payload["current-percentage"] + assert p.marshal() == payload + + +def test_mapped_channel(mapped_channel_payload): + mc = channel_map.MappedChannel.unmarshal(mapped_channel_payload) + + assert ( + repr(mc) + ) == "" + assert mc.channel == mapped_channel_payload["channel"] + assert mc.revision == mapped_channel_payload["revision"] + assert mc.architecture == mapped_channel_payload["architecture"] + assert isinstance(mc.progressive, channel_map.Progressive) + assert mc.expiration_date is None + assert mc.marshal() == mapped_channel_payload + + +def test_snap_channel_with_expiration(mapped_channel_payload): + date_string = "2020-02-11T17:51:40.891996Z" + mapped_channel_payload.update({"expiration-date": date_string}) + + mc = channel_map.MappedChannel.unmarshal(mapped_channel_payload) + + assert ( + repr(mc) + ) == "" + assert mc.channel == mapped_channel_payload["channel"] + assert mc.revision == mapped_channel_payload["revision"] + assert mc.architecture == mapped_channel_payload["architecture"] + assert isinstance(mc.progressive, channel_map.Progressive) + assert mc.expiration_date == date_string + assert mc.marshal() == mapped_channel_payload + + +def test_snap_channel(channel_payload): + sc = channel_map.SnapChannel.unmarshal(channel_payload) + + assert repr(sc) == "" + assert sc.name == channel_payload["name"] + assert sc.track == channel_payload["track"] + assert sc.risk == channel_payload["risk"] + assert sc.branch is None + assert sc.fallback is None + assert sc.marshal() == channel_payload + + +def test_snap_channel_with_branch(channel_payload): + channel_payload.update({"branch": "test-branch"}) + + sc = channel_map.SnapChannel.unmarshal(channel_payload) + + assert repr(sc) == "" + assert sc.name == channel_payload["name"] + assert sc.track == channel_payload["track"] + assert sc.risk == channel_payload["risk"] + assert sc.branch == channel_payload["branch"] + assert sc.fallback is None + assert sc.marshal() == channel_payload + + +def test_snap_channel_with_fallback(channel_payload): + channel_payload.update({"fallback": "latest/stable"}) + + sc = channel_map.SnapChannel.unmarshal(channel_payload) + + assert repr(sc) == "" + assert sc.name == channel_payload["name"] + assert sc.track == channel_payload["track"] + assert sc.risk == channel_payload["risk"] + assert sc.branch is None + assert sc.fallback == channel_payload["fallback"] + assert sc.marshal() == channel_payload + + +_TRACK_PAYLOADS = [ + { + "name": "latest", + "status": "active", + "creation-date": None, + "version-pattern": None, + }, + { + "name": "1.0", + "status": "default", + "creation-date": "2019-10-17T14:11:59Z", + "version-pattern": "1.*", + }, +] + + +@pytest.mark.parametrize("payload", _TRACK_PAYLOADS) +def test_snap_track(payload): + st = channel_map.SnapTrack.unmarshal(payload) + + assert repr(st) == f"" + assert st.name == payload["name"] + assert st.status == payload["status"] + assert st.creation_date == payload["creation-date"] + assert st.version_pattern == payload["version-pattern"] + assert st.marshal() == payload + + +def test_revision(): + payload = {"revision": 2, "version": "2.0", "architectures": ["amd64", "arm64"]} + + r = channel_map.Revision.unmarshal(payload) + + assert repr(r) == ( + "" + ) + assert r.revision == payload["revision"] + assert r.version == payload["version"] + assert r.architectures == payload["architectures"] + assert r.marshal() == payload + + +def test_snap(): + payload = { + "name": "my-snap", + "channels": [ + { + "name": "latest/stable", + "track": "latest", + "risk": "candidate", + "branch": None, + "fallback": None, + }, + { + "name": "latest/candidate", + "track": "latest", + "risk": "candidate", + "branch": None, + "fallback": "latest/stable", + }, + ], + "tracks": [ + { + "name": "track1", + "creation-date": "2019-10-17T14:11:59Z", + "status": "default", + "version-pattern": None, + }, + { + "name": "track2", + "creation-date": None, + "status": "active", + "version-pattern": None, + }, + ], + } + + s = channel_map.Snap.unmarshal(payload) + + assert repr(s) == "" + assert s.name == payload["name"] + + snap_channels = s.channels + assert len(snap_channels) == 2 + assert isinstance(snap_channels[0], channel_map.SnapChannel) + assert isinstance(snap_channels[1], channel_map.SnapChannel) + + assert s.marshal() == payload + + +def test_channel_map(): + payload = { + "channel-map": [ + { + "architecture": "amd64", + "channel": "latest/stable", + "expiration-date": None, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + "revision": 2, + }, + { + "architecture": "amd64", + "channel": "latest/stable", + "expiration-date": None, + "progressive": { + "paused": None, + "percentage": 33.3, + "current-percentage": 12.3, + }, + "revision": 3, + }, + { + "architecture": "arm64", + "channel": "latest/stable", + "expiration-date": None, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + "revision": 2, + }, + { + "architecture": "i386", + "channel": "latest/stable", + "expiration-date": None, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + "revision": 4, + }, + ], + "revisions": [ + {"revision": 2, "version": "2.0", "architectures": ["amd64", "arm64"]}, + {"revision": 3, "version": "2.0", "architectures": ["amd64", "arm64"]}, + {"revision": 4, "version": "2.0", "architectures": ["i386"]}, + ], + "snap": { + "name": "my-snap", + "channels": [ + { + "name": "latest/stable", + "track": "latest", + "risk": "candidate", + "branch": None, + "fallback": None, + }, + { + "name": "latest/candidate", + "track": "latest", + "risk": "candidate", + "branch": None, + "fallback": "latest/stable", + }, + ], + "tracks": [ + { + "name": "track1", + "creation-date": "2019-10-17T14:11:59Z", + "status": "default", + "version-pattern": None, + }, + { + "name": "track2", + "creation-date": None, + "status": "active", + "version-pattern": None, + }, + ], + }, + } + + cm = channel_map.ChannelMap.unmarshal(payload) + + # Check "channel-map". + assert len(cm.channel_map) == 4 + assert isinstance(cm.channel_map[0], channel_map.MappedChannel) + assert isinstance(cm.channel_map[1], channel_map.MappedChannel) + assert isinstance(cm.channel_map[2], channel_map.MappedChannel) + assert isinstance(cm.channel_map[3], channel_map.MappedChannel) + + # Check "revisions". + assert len(cm.revisions) == 3 + assert isinstance(cm.revisions[0], channel_map.Revision) + assert isinstance(cm.revisions[1], channel_map.Revision) + assert isinstance(cm.revisions[2], channel_map.Revision) + + # Check "snap". + assert isinstance(cm.snap, channel_map.Snap) + + # Marshal. + assert cm.marshal() == payload + + # Test the get_mapped_channel method. + assert ( + cm.get_mapped_channel( + channel_name="latest/stable", architecture="amd64", progressive=False + ) + ) == cm.channel_map[0] + assert ( + cm.get_mapped_channel( + channel_name="latest/stable", architecture="amd64", progressive=True + ) + ) == cm.channel_map[1] + with pytest.raises(ValueError): + cm.get_mapped_channel( + channel_name="latest/stable", + architecture="arm64", + progressive=True, + ) + with pytest.raises(ValueError): + cm.get_mapped_channel( + channel_name="latest/stable", + architecture="i386", + progressive=True, + ) + + # Test the get_channel_info method. + assert cm.get_channel_info("latest/stable") == cm.snap.channels[0] + with pytest.raises(ValueError): + cm.get_channel_info("other-track/stable") + + # Test the get_revision method. + assert cm.get_revision(4) == cm.revisions[2] + with pytest.raises(ValueError): + cm.get_revision(5) + + # Test the get_existing_architectures method. + assert cm.get_existing_architectures() == set(["arm64", "amd64", "i386"]) diff --git a/tests/unit/commands/store/test_client.py b/tests/unit/commands/store/test_client.py new file mode 100644 index 0000000000..eada9c74e5 --- /dev/null +++ b/tests/unit/commands/store/test_client.py @@ -0,0 +1,794 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import textwrap +import time +from unittest.mock import call + +import craft_store +import pytest +import requests +from craft_store import endpoints + +from snapcraft import errors +from snapcraft.commands.store import client +from snapcraft.commands.store.channel_map import ChannelMap +from snapcraft.utils import OSPlatform + +from .utils import FakeResponse + +############# +# Fixtures # +############# + + +@pytest.fixture +def no_wait(monkeypatch): + monkeypatch.setattr(time, "sleep", lambda x: None) + + +@pytest.fixture +def channel_map_payload(): + return { + "channel-map": [ + { + "architecture": "all", + "channel": "2.1/beta", + "expiration-date": None, + "revision": 1, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + "when": "2020-02-03T20:58:37Z", + } + ], + "revisions": [ + { + "architectures": [ + "amd64", + "arm64", + "armhf", + "i386", + "s390x", + "ppc64el", + ], + "revision": 1, + "version": "10", + } + ], + "snap": { + "name": "test-snap", + "channels": [ + { + "branch": None, + "fallback": None, + "name": "2.1/stable", + "risk": "stable", + "track": "2.1", + }, + { + "branch": None, + "fallback": "2.1/stable", + "name": "2.1/candidate", + "risk": "candidate", + "track": "2.1", + }, + { + "branch": None, + "fallback": "2.1/candidate", + "name": "2.1/beta", + "risk": "beta", + "track": "2.1", + }, + { + "branch": None, + "fallback": "2.1/beta", + "name": "2.1/edge", + "risk": "edge", + "track": "2.1", + }, + ], + "tracks": [ + { + "name": "latest", + "status": "active", + "creation-date": None, + "version-pattern": None, + }, + { + "name": "1.0", + "status": "default", + "creation-date": "2019-10-17T14:11:59Z", + "version-pattern": "1.*", + }, + ], + "default-track": "2.1", + }, + } + + +#################### +# User Agent Tests # +#################### + + +def test_useragent_linux(): + """Construct a user-agent as a patched Linux machine""" + os_platform = OSPlatform( + system="Arch Linux", release="5.10.10-arch1-1", machine="x86_64" + ) + + assert client.build_user_agent(version="7.1.0", os_platform=os_platform) == ( + "snapcraft/7.1.0 Arch Linux/5.10.10-arch1-1 (x86_64)" + ) + + +@pytest.mark.parametrize("testing_env", ("TRAVIS_TESTING", "AUTOPKGTEST_TMP")) +def test_useragent_linux_with_testing(monkeypatch, testing_env): + """Construct a user-agent as a patched Linux machine""" + monkeypatch.setenv(testing_env, "1") + os_platform = OSPlatform( + system="Arch Linux", release="5.10.10-arch1-1", machine="x86_64" + ) + + assert client.build_user_agent(version="7.1.0", os_platform=os_platform) == ( + "snapcraft/7.1.0 (testing) Arch Linux/5.10.10-arch1-1 (x86_64)" + ) + + +@pytest.mark.parametrize("testing_env", ("TRAVIS_TESTING", "AUTOPKGTEST_TMP")) +def test_useragent_windows_with_testing(monkeypatch, testing_env): + """Construct a user-agent as a patched Windows machine""" + monkeypatch.setenv(testing_env, "1") + os_platform = OSPlatform(system="Windows", release="10", machine="AMD64") + + assert client.build_user_agent(version="7.1.0", os_platform=os_platform) == ( + "snapcraft/7.1.0 (testing) Windows/10 (AMD64)" + ) + + +##################### +# Store Environment # +##################### + + +@pytest.mark.parametrize("env, expected", (("candid", True), ("not-candid", False))) +def test_use_candid(monkeypatch, env, expected): + monkeypatch.setenv("SNAPCRAFT_STORE_AUTH", env) + + assert client.use_candid() is expected + + +def test_get_store_url(): + assert client.get_store_url() == "https://dashboard.snapcraft.io" + + +def test_get_store_url_from_env(monkeypatch): + monkeypatch.setenv("STORE_DASHBOARD_URL", "https://fake-store.io") + + assert client.get_store_url() == "https://fake-store.io" + + +def test_get_store_upload_url(): + assert client.get_store_upload_url() == "https://storage.snapcraftcontent.com" + + +def test_get_store_url_upload_from_env(monkeypatch): + monkeypatch.setenv("STORE_UPLOAD_URL", "https://fake-store-upload.io") + + assert client.get_store_upload_url() == "https://fake-store-upload.io" + + +def test_get_store_login_url(): + assert client.get_store_login_url() == "https://login.ubuntu.com" + + +def test_get_store_login_from_env(monkeypatch): + monkeypatch.setenv("UBUNTU_ONE_SSO_URL", "https://fake-login.io") + + assert client.get_store_login_url() == "https://fake-login.io" + + +#################### +# Host Environment # +#################### + + +def test_get_hostname_none_is_unkown(): + assert client._get_hostname(hostname=None) == "UNKNOWN" + + +def test_get_hostname(): + assert client._get_hostname(hostname="acme") == "acme" + + +####################### +# StoreClient factory # +####################### + + +@pytest.mark.parametrize("ephemeral", (True, False)) +def test_get_store_client(monkeypatch, ephemeral): + monkeypatch.setenv("SNAPCRAFT_STORE_AUTH", "candid") + + store_client = client.get_client(ephemeral) + + assert isinstance(store_client, craft_store.StoreClient) + + +@pytest.mark.parametrize("ephemeral", (True, False)) +def test_get_ubuntu_client(ephemeral): + store_client = client.get_client(ephemeral) + + assert isinstance(store_client, craft_store.UbuntuOneStoreClient) + + +################## +# StoreClientCLI # +################## + + +@pytest.fixture +def fake_user_password(mocker): + """Return a canned user name and password""" + mocker.patch.object( + client, + "_prompt_login", + return_value=("fake-username@acme.com", "fake-password"), + ) + + +@pytest.fixture +def fake_otp(mocker): + """Return a canned user name and password""" + mocker.patch.object( + client.utils, + "prompt", + return_value="123456", + ) + + +@pytest.fixture +def fake_hostname(mocker): + mocker.patch.object(client, "_get_hostname", return_value="fake-host") + + +@pytest.mark.usefixtures("fake_user_password", "fake_hostname") +def test_login(fake_client): + client.StoreClientCLI().login() + + assert fake_client.login.mock_calls == [ + call( + ttl=31536000, + permissions=[ + "package_access", + "package_manage", + "package_metrics", + "package_push", + "package_register", + "package_release", + "package_update", + ], + channels=None, + packages=[], + description="snapcraft@fake-host", + email="fake-username@acme.com", + password="fake-password", + ) + ] + + +@pytest.mark.usefixtures("fake_user_password", "fake_otp", "fake_hostname") +def test_login_otp(fake_client): + fake_client.login.side_effect = [ + craft_store.errors.StoreServerError( + FakeResponse( + status_code=requests.codes.unauthorized, # pylint: disable=no-member + content=json.dumps( + {"error_list": [{"message": "2fa", "code": "twofactor-required"}]} + ), + ) + ), + None, + ] + + client.StoreClientCLI().login() + + assert fake_client.login.mock_calls == [ + call( + ttl=31536000, + permissions=[ + "package_access", + "package_manage", + "package_metrics", + "package_push", + "package_register", + "package_release", + "package_update", + ], + channels=None, + packages=[], + description="snapcraft@fake-host", + email="fake-username@acme.com", + password="fake-password", + ), + call( + ttl=31536000, + permissions=[ + "package_access", + "package_manage", + "package_metrics", + "package_push", + "package_register", + "package_release", + "package_update", + ], + channels=None, + packages=[], + description="snapcraft@fake-host", + email="fake-username@acme.com", + password="fake-password", + otp="123456", + ), + ] + + +@pytest.mark.usefixtures("fake_user_password", "fake_hostname") +def test_login_with_params(fake_client): + client.StoreClientCLI().login( + ttl=20, + acls=["package_access", "package_push"], + packages=["fake-snap", "fake-other-snap"], + channels=["stable/fake", "edge/fake"], + ) + + assert fake_client.login.mock_calls == [ + call( + ttl=20, + permissions=[ + "package_access", + "package_push", + ], + channels=["stable/fake", "edge/fake"], + packages=[ + endpoints.Package(package_name="fake-snap", package_type="snap"), + endpoints.Package(package_name="fake-other-snap", package_type="snap"), + ], + description="snapcraft@fake-host", + email="fake-username@acme.com", + password="fake-password", + ) + ] + + +########### +# Request # +########### + + +@pytest.mark.usefixtures("fake_user_password", "fake_hostname") +def test_login_from_401_request(fake_client): + fake_client.request.side_effect = [ + craft_store.errors.StoreServerError( + FakeResponse( + status_code=401, + content=json.dumps( + { + "error_list": [ + { + "code": "macaroon-needs-refresh", + "message": "Expired macaroon (age: 1234567 seconds)", + } + ] + } + ), + ) + ), + FakeResponse(status_code=200, content="text"), + ] + + client.StoreClientCLI().request("GET", "http://url.com/path") + + assert fake_client.request.mock_calls == [ + call("GET", "http://url.com/path"), + call("GET", "http://url.com/path"), + ] + assert fake_client.login.mock_calls == [ + call( + ttl=31536000, + permissions=[ + "package_access", + "package_manage", + "package_metrics", + "package_push", + "package_register", + "package_release", + "package_update", + ], + channels=None, + packages=[], + description="snapcraft@fake-host", + email="fake-username@acme.com", + password="fake-password", + ) + ] + + +def test_login_from_401_request_with_env_credentials(monkeypatch, fake_client): + monkeypatch.setenv(client.constants.ENVIRONMENT_STORE_CREDENTIALS, "foo") + fake_client.request.side_effect = [ + craft_store.errors.StoreServerError( + FakeResponse( + status_code=401, + content=json.dumps( + { + "error_list": [ + { + "code": "macaroon-needs-refresh", + "message": "Expired macaroon (age: 1234567 seconds)", + } + ] + } + ), + ) + ), + ] + + with pytest.raises(errors.SnapcraftError) as raised: + client.StoreClientCLI().request("GET", "http://url.com/path") + + assert str(raised.value) == ( + "Provided credentials are no longer valid for the Snap Store. " + "Regenerate them and try again." + ) + + +############ +# Register # +############ + + +@pytest.mark.parametrize("private", [True, False]) +@pytest.mark.parametrize("store_id", [None, "one-store", "other-store"]) +def test_register(fake_client, private, store_id): + client.StoreClientCLI().register("snap", is_private=private, store_id=store_id) + + expected_json = { + "snap_name": "snap", + "is_private": private, + "series": "16", + } + if store_id: + expected_json["store"] = store_id + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/register-name/", + json=expected_json, + ) + ] + + +########################### +# Get Account Information # +########################### + + +def test_get_account_info(fake_client): + client.StoreClientCLI().get_account_info() + + assert fake_client.request.mock_calls == [ + call( + "GET", + "https://dashboard.snapcraft.io/dev/api/account", + headers={"Accept": "application/json"}, + ), + call().json(), + ] + + +########### +# Release # +########### + + +@pytest.mark.parametrize("progressive_percentage", [None, 100]) +def test_release(fake_client, progressive_percentage): + client.StoreClientCLI().release( + snap_name="snap", + revision=10, + channels=["beta", "edge"], + progressive_percentage=progressive_percentage, + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-release/", + json={"name": "snap", "revision": "10", "channels": ["beta", "edge"]}, + ) + ] + + +def test_release_progressive(fake_client): + client.StoreClientCLI().release( + snap_name="snap", + revision=10, + channels=["beta", "edge"], + progressive_percentage=88, + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-release/", + json={ + "name": "snap", + "revision": "10", + "channels": ["beta", "edge"], + "progressive": {"percentage": 88, "paused": False}, + }, + ) + ] + + +######### +# Close # +######### + + +def test_close(fake_client): + client.StoreClientCLI().close( + snap_id="12345", + channel="edge", + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snaps/12345/close", + json={"channels": ["edge"]}, + ) + ] + + +################### +# Get Channel Map # +################### + + +def test_get_channel_map(fake_client, channel_map_payload): + fake_client.request.return_value = FakeResponse( + status_code=200, content=json.dumps(channel_map_payload) + ) + channel_map = client.StoreClientCLI().get_channel_map( + snap_name="test-snap", + ) + assert isinstance(channel_map, ChannelMap) + + assert fake_client.request.mock_calls == [ + call( + "GET", + "https://dashboard.snapcraft.io/api/v2/snaps/test-snap/channel-map", + headers={"Accept": "application/json"}, + ) + ] + + +################# +# Verify Upload # +################# + + +def test_verify_upload(fake_client): + client.StoreClientCLI().verify_upload(snap_name="foo") + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-push/", + json={"name": "foo", "dry_run": True}, + headers={"Accept": "application/json"}, + ) + ] + + +################# +# Notify Upload # +################# + + +@pytest.mark.usefixtures("no_wait") +def test_notify_upload(fake_client): + fake_client.request.side_effect = [ + FakeResponse( + status_code=200, content=json.dumps({"status_details_url": "https://track"}) + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "processing", "processed": False}), + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "done", "processed": True, "revision": 42}), + ), + ] + + client.StoreClientCLI().notify_upload( + snap_name="foo", + upload_id="some-id", + channels=None, + built_at=None, + snap_file_size=999, + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-push/", + json={ + "name": "foo", + "series": "16", + "updown_id": "some-id", + "binary_filesize": 999, + "source_uploaded": False, + }, + headers={"Accept": "application/json"}, + ), + call("GET", "https://track"), + call("GET", "https://track"), + ] + + +@pytest.mark.usefixtures("no_wait") +def test_notify_upload_built_at(fake_client): + fake_client.request.side_effect = [ + FakeResponse( + status_code=200, content=json.dumps({"status_details_url": "https://track"}) + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "processing", "processed": False}), + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "done", "processed": True, "revision": 42}), + ), + ] + + client.StoreClientCLI().notify_upload( + snap_name="foo", + upload_id="some-id", + channels=None, + built_at="some-date", + snap_file_size=999, + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-push/", + json={ + "name": "foo", + "series": "16", + "updown_id": "some-id", + "binary_filesize": 999, + "source_uploaded": False, + "built_at": "some-date", + }, + headers={"Accept": "application/json"}, + ), + call("GET", "https://track"), + call("GET", "https://track"), + ] + + +@pytest.mark.usefixtures("no_wait") +def test_notify_upload_channels(fake_client): + fake_client.request.side_effect = [ + FakeResponse( + status_code=200, content=json.dumps({"status_details_url": "https://track"}) + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "processing", "processed": False}), + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "done", "processed": True, "revision": 42}), + ), + ] + + client.StoreClientCLI().notify_upload( + snap_name="foo", + upload_id="some-id", + channels=["stable"], + built_at=None, + snap_file_size=999, + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-push/", + json={ + "name": "foo", + "series": "16", + "updown_id": "some-id", + "binary_filesize": 999, + "channels": ["stable"], + "source_uploaded": False, + }, + headers={"Accept": "application/json"}, + ), + call("GET", "https://track"), + call("GET", "https://track"), + ] + + +@pytest.mark.usefixtures("no_wait") +def test_notify_upload_error(fake_client): + fake_client.request.side_effect = [ + FakeResponse( + status_code=200, content=json.dumps({"status_details_url": "https://track"}) + ), + FakeResponse( + status_code=200, + content=json.dumps({"code": "processing", "processed": False}), + ), + FakeResponse( + status_code=200, + content=json.dumps( + {"code": "done", "processed": True, "errors": [{"message": "bad-snap"}]} + ), + ), + ] + + with pytest.raises(errors.SnapcraftError) as raised: + client.StoreClientCLI().notify_upload( + snap_name="foo", + upload_id="some-id", + channels=["stable"], + built_at=None, + snap_file_size=999, + ) + + assert str(raised.value) == textwrap.dedent( + """\ + Issues while processing snap: + - bad-snap""" + ) + + assert fake_client.request.mock_calls == [ + call( + "POST", + "https://dashboard.snapcraft.io/dev/api/snap-push/", + json={ + "name": "foo", + "series": "16", + "updown_id": "some-id", + "binary_filesize": 999, + "channels": ["stable"], + "source_uploaded": False, + }, + headers={"Accept": "application/json"}, + ), + call("GET", "https://track"), + call("GET", "https://track"), + ] diff --git a/tests/unit/commands/store/utils.py b/tests/unit/commands/store/utils.py new file mode 100644 index 0000000000..a3e5a6d9e5 --- /dev/null +++ b/tests/unit/commands/store/utils.py @@ -0,0 +1,46 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json + +import requests + + +class FakeResponse(requests.Response): + """A fake requests.Response.""" + + def __init__(self, content, status_code): # pylint: disable=super-init-not-called + self._content = content + self.status_code = status_code + + @property + def content(self): + return self._content + + @property + def ok(self): + return self.status_code == 200 + + def json(self, **kwargs): + return json.loads(self._content) # type: ignore + + @property + def reason(self): + return self._content + + @property + def text(self): + return self.content diff --git a/tests/unit/commands/test_account.py b/tests/unit/commands/test_account.py new file mode 100644 index 0000000000..c1b7dfc623 --- /dev/null +++ b/tests/unit/commands/test_account.py @@ -0,0 +1,245 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +from textwrap import dedent +from unittest.mock import ANY, call + +import craft_cli +import pytest + +from snapcraft import commands + +############ +# Fixtures # +############ + + +@pytest.fixture +def fake_store_login(mocker): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.login", + autospec=True, + return_value="secret", + ) + return fake_client + + +################# +# Login Command # +################# + + +@pytest.mark.usefixtures("memory_keyring") +def test_login(emitter, fake_store_login): + cmd = commands.StoreLoginCommand(None) + + cmd.run(argparse.Namespace(login_with=None, experimental_login=False)) + + assert fake_store_login.mock_calls == [ + call( + ANY, + ) + ] + emitter.assert_message("Login successful") + + +def test_login_with_file_fails(): + cmd = commands.StoreLoginCommand(None) + + with pytest.raises(craft_cli.errors.ArgumentParsingError) as raised: + cmd.run(argparse.Namespace(login_with="fake-file", experimental_login=False)) + + assert str(raised.value) == ( + "--with is no longer supported, export the auth to the environment " + "variable 'SNAPCRAFT_STORE_CREDENTIALS' instead" + ) + + +def test_login_with_experimental_fails(): + cmd = commands.StoreLoginCommand(None) + + with pytest.raises(craft_cli.errors.ArgumentParsingError) as raised: + cmd.run(argparse.Namespace(login_with=None, experimental_login=True)) + + assert str(raised.value) == ( + "--experimental-login no longer supported. Set SNAPCRAFT_STORE_AUTH=candid instead" + ) + + +######################## +# Export Login Command # +######################## + + +def test_export_login(emitter, fake_store_login): + cmd = commands.StoreExportLoginCommand(None) + + cmd.run( + argparse.Namespace( + login_file="-", + snaps=None, + channels=None, + acls=None, + expires=None, + experimental_login=False, + ) + ) + + assert fake_store_login.mock_calls == [ + call( + ANY, + ) + ] + emitter.assert_message("Exported login credentials:\nsecret") + + +def test_export_login_file(new_dir, emitter, fake_store_login): + cmd = commands.StoreExportLoginCommand(None) + + cmd.run( + argparse.Namespace( + login_file="target_file", + snaps=None, + channels=None, + acls=None, + expires=None, + experimental_login=False, + ) + ) + + assert fake_store_login.mock_calls == [ + call( + ANY, + ) + ] + emitter.assert_message("Exported login credentials to 'target_file'") + login_file = new_dir / "target_file" + assert login_file.exists() + assert login_file.read_text() == "secret" + + +def test_export_login_with_params(emitter, fake_store_login): + cmd = commands.StoreExportLoginCommand(None) + + cmd.run( + argparse.Namespace( + login_file="-", + snaps="fake-snap,fake-other-snap", + channels="stable,edge", + acls="package_manage,package_push", + expires="2030-12-12", + experimental_login=False, + ) + ) + + assert fake_store_login.mock_calls == [ + call( + ANY, + packages=["fake-snap", "fake-other-snap"], + channels=["stable", "edge"], + acls=["package_manage", "package_push"], + ttl=ANY, + ) + ] + emitter.assert_message("Exported login credentials:\nsecret") + + +def test_export_login_with_experimental_fails(): + cmd = commands.StoreExportLoginCommand(None) + + with pytest.raises(craft_cli.errors.ArgumentParsingError) as raised: + cmd.run( + argparse.Namespace( + login_file="-", + snaps=None, + channels=None, + acls=None, + expires=None, + experimental_login=True, + ) + ) + + assert str(raised.value) == ( + "--experimental-login no longer supported. Set SNAPCRAFT_STORE_AUTH=candid instead" + ) + + +################## +# WhoAmI Command # +################## + + +def test_who(emitter, fake_client): + fake_client.whoami.return_value = { + "account": {"email": "user@acme.org", "id": "id", "username": "user"}, + "expires": "2023-04-22T21:48:57.000", + } + + cmd = commands.StoreWhoAmICommand(None) + + cmd.run(argparse.Namespace()) + + assert fake_client.whoami.mock_calls == [call()] + expected_message = dedent( + """\ + email: user@acme.org + username: user + id: id + permissions: no restrictions + channels: no restrictions + expires: 2023-04-22T21:48:57.000Z""" + ) + emitter.assert_message(expected_message) + + +def test_who_with_attenuations(emitter, fake_client): + fake_client.whoami.return_value = { + "account": {"email": "user@acme.org", "id": "id", "username": "user"}, + "permissions": ["package_manage", "package_access"], + "channels": ["edge", "beta"], + "expires": "2023-04-22T21:48:57.000", + } + + cmd = commands.StoreWhoAmICommand(None) + + cmd.run(argparse.Namespace()) + + assert fake_client.whoami.mock_calls == [call()] + expected_message = dedent( + """\ + email: user@acme.org + username: user + id: id + permissions: package_manage, package_access + channels: edge, beta + expires: 2023-04-22T21:48:57.000Z""" + ) + emitter.assert_message(expected_message) + + +################## +# Logout Command # +################## + + +def test_logout(emitter, fake_client): + cmd = commands.StoreLogoutCommand(None) + + cmd.run(argparse.Namespace()) + + assert fake_client.logout.mock_calls == [call()] + emitter.assert_message("Credentials cleared") diff --git a/tests/unit/commands/test_close.py b/tests/unit/commands/test_close.py deleted file mode 100644 index 031e113914..0000000000 --- a/tests/unit/commands/test_close.py +++ /dev/null @@ -1,108 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2016-2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -from textwrap import dedent - -import fixtures -from testtools.matchers import Contains, Equals - -import snapcraft.storeapi.errors -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase - - -class CloseCommandTestCase(FakeStoreCommandsBaseTestCase): - def setUp(self): - super().setUp() - - self.useFixture( - fixtures.MockPatchObject( - storeapi._dashboard_api.DashboardAPI, - "close_channels", - return_value=(list(), dict()), - ) - ) - - def test_close_missing_permission(self): - self.fake_store_account_info.mock.return_value = { - "account_id": "abcd", - "snaps": {}, - } - - raised = self.assertRaises( - snapcraft.storeapi.errors.StoreChannelClosingPermissionError, - self.run_command, - ["close", "foo", "beta"], - ) - - self.assertThat( - str(raised), - Equals( - "Your account lacks permission to close channels for this snap. " - "Make sure the logged in account has upload permissions on " - "'foo' in series '16'." - ), - ) - - def test_close(self): - result = self.run_command(["close", "snap-test", "2.1/candidate"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 amd64 stable - - - candidate - - - beta 10 19 - edge ↑ ↑ - - The 2.1/candidate channel is now closed.""" - ) - ), - ) - - def test_close_no_revisions(self): - self.channel_map.channel_map = list() - - result = self.run_command(["close", "snap-test", "2.1/candidate"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output.strip(), Equals("The 2.1/candidate channel is now closed.") - ) - - def test_close_multiple_channels(self): - result = self.run_command(["close", "snap-test", "2.1/stable", "2.1/edge/test"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 amd64 stable - - - candidate - - - beta 10 19 - edge ↑ ↑ - - The 2.1/stable and 2.1/edge/test channels are now closed.""" - ) - ), - ) diff --git a/tests/unit/commands/test_expand_extensions.py b/tests/unit/commands/test_expand_extensions.py new file mode 100644 index 0000000000..f218df2b8e --- /dev/null +++ b/tests/unit/commands/test_expand_extensions.py @@ -0,0 +1,76 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from argparse import Namespace +from pathlib import Path +from textwrap import dedent + +import pytest + +from snapcraft.commands import ExpandExtensionsCommand + + +@pytest.mark.usefixtures("fake_extension") +def test_command(new_dir, emitter): + with Path("snapcraft.yaml").open("w") as yaml_file: + print( + dedent( + """\ + name: test-name + version: "0.1" + summary: testing extensions + description: expand a fake extension + base: core22 + + apps: + app1: + command: app1 + extensions: [fake-extension] + + parts: + part1: + plugin: nil + """ + ), + file=yaml_file, + ) + + cmd = ExpandExtensionsCommand(None) + cmd.run(Namespace()) + emitter.assert_message( + dedent( + """\ + name: test-name + version: '0.1' + summary: testing extensions + description: expand a fake extension + base: core22 + apps: + app1: + command: app1 + plugs: + - fake-plug + parts: + part1: + plugin: nil + after: + - fake-extension/fake-part + fake-extension/fake-part: + plugin: nil + grade: fake-grade + """ + ) + ) diff --git a/tests/unit/commands/test_export_login.py b/tests/unit/commands/test_export_login.py deleted file mode 100644 index 1c086db5ad..0000000000 --- a/tests/unit/commands/test_export_login.py +++ /dev/null @@ -1,229 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2016-2019 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import re -from unittest import mock - -import fixtures -import pytest -from testtools.matchers import Contains, Equals, MatchesRegex, Not - -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase - - -class ExportLoginCommandTestCase(FakeStoreCommandsBaseTestCase): - def setUp(self): - super().setUp() - - self.useFixture( - fixtures.MockPatchObject( - storeapi.StoreClient, - "acl", - return_value={ - "snap_ids": None, - "channels": None, - "permissions": None, - "expires": "2018-01-01T00:00:00", - }, - ) - ) - - def test_successful_export(self): - result = self.run_command( - ["export-login", "exported"], input="user@example.com\nsecret\n" - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains(storeapi.constants.TWO_FACTOR_WARNING)) - self.assertThat(result.output, Contains("Login successfully exported")) - self.assertThat( - result.output, MatchesRegex(r".*snaps:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*channels:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*permissions:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*expires:.*?2018-01-01T00:00:00", re.DOTALL) - ) - - self.fake_store_login.mock.assert_called_once_with( - email="user@example.com", - password="secret", - acls=None, - packages=None, - channels=None, - expires=None, - save=False, - config_fd=None, - ) - - def test_successful_export_stdout(self): - result = self.run_command( - ["export-login", "-"], input="user@example.com\nsecret\n" - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains(storeapi.constants.TWO_FACTOR_WARNING)) - self.assertThat(result.output, Contains("Exported login starts on next line")) - self.assertThat( - result.output, Contains("Login successfully exported and printed above") - ) - self.assertThat( - result.output, MatchesRegex(r".*snaps:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*channels:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*permissions:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*expires:.*?2018-01-01T00:00:00", re.DOTALL) - ) - - self.fake_store_login.mock.assert_called_once_with( - email="user@example.com", - password="secret", - acls=None, - packages=None, - channels=None, - expires=None, - save=False, - config_fd=None, - ) - - def test_successful_export_expires(self): - self.useFixture( - fixtures.MockPatchObject( - storeapi.StoreClient, - "acl", - return_value={ - "snap_ids": None, - "channels": None, - "permissions": None, - "expires": "2018-02-01T00:00:00", - }, - ) - ) - - result = self.run_command( - ["export-login", "--expires=2018-02-01T00:00:00", "exported"], - input="user@example.com\nsecret\n", - ) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains(storeapi.constants.TWO_FACTOR_WARNING)) - self.assertThat(result.output, Contains("Login successfully exported")) - self.assertThat( - result.output, MatchesRegex(r".*snaps:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*channels:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*permissions:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*expires:.*?2018-02-01T00:00:00", re.DOTALL) - ) - - self.fake_store_login.mock.assert_called_once_with( - email="user@example.com", - password=mock.ANY, - acls=None, - packages=None, - channels=None, - expires="2018-02-01T00:00:00", - save=False, - config_fd=None, - ) - - def test_successful_login_with_2fa(self): - self.fake_store_login.mock.side_effect = [ - storeapi.http_clients.errors.StoreTwoFactorAuthenticationRequired(), - None, - ] - - result = self.run_command( - ["export-login", "exported"], input="user@example.com\nsecret\n123456" - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, Not(Contains(storeapi.constants.TWO_FACTOR_WARNING)) - ) - self.assertThat(result.output, Contains("Login successfully exported")) - self.assertThat( - result.output, MatchesRegex(r".*snaps:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*channels:.*?['edge']", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*permissions:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*expires:.*?2018-01-01T00:00:00", re.DOTALL) - ) - - self.assertThat(self.fake_store_login.mock.call_count, Equals(2)) - self.fake_store_login.mock.assert_has_calls( - [ - mock.call( - email="user@example.com", - password="secret", - acls=None, - packages=None, - channels=None, - expires=None, - save=False, - config_fd=None, - ), - mock.call( - email="user@example.com", - password="secret", - otp="123456", - acls=None, - packages=None, - channels=None, - expires=None, - save=False, - config_fd=None, - ), - ] - ) - - def test_failed_login_with_invalid_credentials(self): - self.fake_store_login.mock.side_effect = ( - storeapi.http_clients.errors.InvalidCredentialsError("error") - ) - - with pytest.raises( - storeapi.http_clients.errors.InvalidCredentialsError - ) as exc_info: - self.run_command( - ["export-login", "exported"], - input="bad-user@example.com\nbad-password\n", - ) - - assert ( - str(exc_info.value) - == 'Invalid credentials: error. Have you run "snapcraft login"?' - ) diff --git a/tests/unit/commands/test_lifecycle.py b/tests/unit/commands/test_lifecycle.py new file mode 100644 index 0000000000..06bab102f3 --- /dev/null +++ b/tests/unit/commands/test_lifecycle.py @@ -0,0 +1,97 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +from unittest.mock import call + +import pytest + +from snapcraft.commands.lifecycle import ( + BuildCommand, + CleanCommand, + PackCommand, + PrimeCommand, + PullCommand, + SnapCommand, + StageCommand, +) + + +@pytest.mark.parametrize( + "cmd_name,cmd_class", + [ + ("pull", PullCommand), + ("build", BuildCommand), + ("stage", StageCommand), + ("prime", PrimeCommand), + ("clean", CleanCommand), + ], +) +def test_lifecycle_command(cmd_name, cmd_class, mocker): + lifecycle_run_mock = mocker.patch("snapcraft.parts.lifecycle.run") + cmd = cmd_class(None) + cmd.run(argparse.Namespace(parts=["part1", "part2"])) + assert lifecycle_run_mock.mock_calls == [ + call(cmd_name, argparse.Namespace(parts=["part1", "part2"])) + ] + + +@pytest.mark.parametrize( + "cmd_name,cmd_class", + [ + ("pack", PackCommand), + ("snap", SnapCommand), + ], +) +def test_pack_command(mocker, cmd_name, cmd_class): + lifecycle_run_mock = mocker.patch("snapcraft.parts.lifecycle.run") + cmd = cmd_class(None) + cmd.run(argparse.Namespace(directory=None, output=None, compression=None)) + assert lifecycle_run_mock.mock_calls == [ + call( + cmd_name, argparse.Namespace(directory=None, output=None, compression=None) + ) + ] + + +@pytest.mark.parametrize( + "cmd_name,cmd_class", + [ + ("pack", PackCommand), + ("snap", SnapCommand), + ], +) +def test_pack_command_with_output(mocker, cmd_name, cmd_class): + lifecycle_run_mock = mocker.patch("snapcraft.parts.lifecycle.run") + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + cmd = cmd_class(None) + cmd.run(argparse.Namespace(directory=None, output="output", compression=None)) + assert lifecycle_run_mock.mock_calls == [ + call( + cmd_name, + argparse.Namespace(compression=None, directory=None, output="output"), + ) + ] + assert pack_mock.mock_calls == [] + + +def test_pack_command_with_directory(mocker): + lifecycle_run_mock = mocker.patch("snapcraft.parts.lifecycle.run") + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + cmd = PackCommand(None) + cmd.run(argparse.Namespace(directory=".", output=None, compression=None)) + assert lifecycle_run_mock.mock_calls == [] + assert pack_mock.mock_calls == [call(".", output=None)] diff --git a/tests/unit/commands/test_list.py b/tests/unit/commands/test_list.py deleted file mode 100644 index 8332351c82..0000000000 --- a/tests/unit/commands/test_list.py +++ /dev/null @@ -1,131 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2016-2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from textwrap import dedent - -from testtools.matchers import Contains, Equals - -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase - - -class ListTest(FakeStoreCommandsBaseTestCase): - - command_name = "list" - - def test_command_without_login_must_ask(self): - # TODO: look into why this many calls are done inside snapcraft.storeapi - self.fake_store_account_info.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), - {"account_id": "abcd", "snaps": dict()}, - {"account_id": "abcd", "snaps": dict()}, - {"account_id": "abcd", "snaps": dict()}, - ] - - result = self.run_command( - [self.command_name], input="user@example.com\nsecret\n" - ) - self.assertThat( - result.output, Contains("You are required to login before continuing.") - ) - - def test_list_empty(self): - self.fake_store_account_info.mock.return_value = { - "account_id": "abcd", - "snaps": dict(), - } - - result = self.run_command([self.command_name]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("There are no registered snaps.")) - - def test_list_registered(self): - self.command_name = "list-registered" - self.fake_store_account_info.mock.return_value = { - "account_id": "abcd", - "snaps": dict(), - } - - result = self.run_command([self.command_name]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("There are no registered snaps.")) - - def test_registered(self): - self.command_name = "registered" - self.fake_store_account_info.mock.return_value = { - "account_id": "abcd", - "snaps": dict(), - } - - result = self.run_command([self.command_name]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("There are no registered snaps.")) - - def test_list_successfully(self): - self.fake_store_account_info.mock.return_value = { - "snaps": { - "16": { - "foo": { - "status": "Approved", - "snap-id": "a_snap_id", - "private": False, - "since": "2016-12-12T01:01:01Z", - "price": "9.99", - }, - "bar": { - "status": "ReviewPending", - "snap-id": "another_snap_id", - "private": True, - "since": "2016-12-12T01:01:01Z", - "price": None, - }, - "baz": { - "status": "Approved", - "snap-id": "yet_another_snap_id", - "private": True, - "since": "2016-12-12T02:02:02Z", - "price": "6.66", - }, - "boing": { - "status": "Approved", - "snap-id": "boing_snap_id", - "private": False, - "since": "2016-12-12T03:03:03Z", - "price": None, - }, - } - } - } - - result = self.run_command([self.command_name]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains( - dedent( - """\ - Name Since Visibility Price Notes - baz 2016-12-12T02:02:02Z private 6.66 - - boing 2016-12-12T03:03:03Z public - - - foo 2016-12-12T01:01:01Z public 9.99 -""" - ) - ), - ) diff --git a/tests/unit/commands/test_list_extensions.py b/tests/unit/commands/test_list_extensions.py new file mode 100644 index 0000000000..979c07ac18 --- /dev/null +++ b/tests/unit/commands/test_list_extensions.py @@ -0,0 +1,71 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from argparse import Namespace +from textwrap import dedent + +import pytest + +from snapcraft.commands import ExtensionsCommand, ListExtensionsCommand + + +@pytest.mark.usefixtures("fake_extension") +@pytest.mark.parametrize("command", [ListExtensionsCommand, ExtensionsCommand]) +def test_command(emitter, command): + cmd = command(None) + cmd.run(Namespace()) + emitter.assert_message( + dedent( + """\ + Extension name Supported bases + ---------------- ----------------- + fake-extension core22 + flutter-beta core18 + flutter-dev core18 + flutter-master core18 + flutter-stable core18 + gnome-3-28 core18 + gnome-3-34 core18 + gnome-3-38 core20 + kde-neon core18, core20 + ros1-noetic core20 + ros2-foxy core20""" + ) + ) + + +@pytest.mark.usefixtures("fake_extension_name_from_legacy") +@pytest.mark.parametrize("command", [ListExtensionsCommand, ExtensionsCommand]) +def test_command_extension_dups(emitter, command): + cmd = command(None) + cmd.run(Namespace()) + emitter.assert_message( + dedent( + """\ + Extension name Supported bases + ---------------- ----------------- + flutter-beta core18 + flutter-dev core18 + flutter-master core18 + flutter-stable core18 + gnome-3-28 core18 + gnome-3-34 core18 + gnome-3-38 core20 + kde-neon core18, core20 + ros1-noetic core20 + ros2-foxy core20, core22""" + ) + ) diff --git a/tests/unit/commands/test_list_tracks.py b/tests/unit/commands/test_list_tracks.py deleted file mode 100644 index 4e85b806b8..0000000000 --- a/tests/unit/commands/test_list_tracks.py +++ /dev/null @@ -1,61 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from textwrap import dedent - -from testtools.matchers import Contains, Equals - -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase - - -class ListTracksCommandTestCase(FakeStoreCommandsBaseTestCase): - def test_list_tracks_without_snap_raises_exception(self): - result = self.run_command(["list-tracks"]) - - self.assertThat(result.exit_code, Equals(2)) - self.assertThat(result.output, Contains("Usage:")) - - def test_list_tracks_without_login_must_ask(self): - self.fake_store_get_snap_channel_map.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), - self.channel_map, - ] - - result = self.run_command( - ["list-tracks", "snap-test"], input="user@example.com\nsecret\n" - ) - self.assertThat( - result.output, Contains("You are required to login before continuing.") - ) - - def test_list_tracks(self): - result = self.run_command(["list-tracks", "snap-test"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Name Status Creation-Date Version-Pattern - latest active - - - 2.0 default 2019-10-17T14:11:59Z 2\\.* - """ - ) - ), - ) diff --git a/tests/unit/commands/test_login.py b/tests/unit/commands/test_login.py deleted file mode 100644 index 94bb1aabed..0000000000 --- a/tests/unit/commands/test_login.py +++ /dev/null @@ -1,284 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2016-2019 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import pathlib -import re -from unittest import mock - -import fixtures -import pytest -from simplejson.scanner import JSONDecodeError -from testtools.matchers import Contains, Equals, MatchesRegex, Not - -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase - - -class LoginCommandTestCase(FakeStoreCommandsBaseTestCase): - def test_login(self): - # No 2fa - self.fake_store_login.mock.side_effect = None - - result = self.run_command(["login"], input="user@example.com\nsecret\n") - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains(storeapi.constants.TWO_FACTOR_WARNING)) - self.assertThat(result.output, Contains("Login successful.")) - self.fake_store_login.mock.assert_called_once_with( - email="user@example.com", - password=mock.ANY, - acls=None, - packages=None, - channels=None, - expires=None, - save=True, - config_fd=None, - ) - - def test_login_with_2fa(self): - self.fake_store_login.mock.side_effect = [ - storeapi.http_clients.errors.StoreTwoFactorAuthenticationRequired(), - None, - ] - - # no exception raised. - result = self.run_command(["login"], input="user@example.com\nsecret\n123456\n") - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, Not(Contains(storeapi.constants.TWO_FACTOR_WARNING)) - ) - self.assertThat(result.output, Contains("Login successful.")) - - self.assertThat(self.fake_store_login.mock.call_count, Equals(2)) - self.fake_store_login.mock.assert_has_calls( - [ - mock.call( - email="user@example.com", - password="secret", - acls=None, - packages=None, - channels=None, - expires=None, - save=True, - config_fd=None, - ), - mock.call( - email="user@example.com", - password="secret", - otp="123456", - acls=None, - packages=None, - channels=None, - expires=None, - save=True, - config_fd=None, - ), - ] - ) - - def test_successful_login_with(self): - self.useFixture( - fixtures.MockPatchObject( - storeapi.StoreClient, - "acl", - return_value={ - "snap_ids": None, - "channels": None, - "permissions": None, - "expires": "2018-01-01T00:00:00", - }, - ) - ) - self.fake_store_login.mock.side_effect = None - - pathlib.Path("exported-login").touch() - - result = self.run_command(["login", "--with", "exported-login"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Login successful")) - self.assertThat( - result.output, MatchesRegex(r".*snaps:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*channels:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*permissions:.*?No restriction", re.DOTALL) - ) - self.assertThat( - result.output, MatchesRegex(r".*expires:.*?2018-01-01T00:00:00", re.DOTALL) - ) - - self.fake_store_login.mock.assert_called_once_with( - email="", - password="", - acls=None, - packages=None, - channels=None, - expires=None, - save=True, - config_fd=mock.ANY, - ) - - def test_login_failed_with_invalid_credentials(self): - self.fake_store_login.mock.side_effect = ( - storeapi.http_clients.errors.InvalidCredentialsError("error") - ) - - with pytest.raises( - storeapi.http_clients.errors.InvalidCredentialsError - ) as exc_info: - self.run_command(["login"], input="user@example.com\nbadsecret\n") - - assert ( - str(exc_info.value) - == 'Invalid credentials: error. Have you run "snapcraft login"?' - ) - - def test_login_failed_with_store_authentication_error(self): - self.fake_store_login.mock.side_effect = ( - storeapi.http_clients.errors.StoreAuthenticationError("error") - ) - - raised = self.assertRaises( - storeapi.http_clients.errors.StoreAuthenticationError, - self.run_command, - ["login"], - input="user@example.com\nbad-secret\n", - ) - - self.assertThat(raised.message, Equals("error")) - - def test_failed_login_with_store_account_info_error(self): - response = mock.Mock() - response.json.side_effect = JSONDecodeError("mock-fail", "doc", 1) - response.status_code = 500 - response.reason = "Internal Server Error" - self.fake_store_login.mock.side_effect = ( - storeapi.errors.StoreAccountInformationError(response) - ) - - with pytest.raises(storeapi.errors.StoreAccountInformationError) as exc_info: - self.run_command(["login"], input="user@example.com\nsecret\n\n") - - assert ( - str(exc_info.value) - == "Error fetching account information from store: 500 Internal Server Error" - ) - - def test_failed_login_with_dev_namespace_error(self): - response = mock.Mock() - response.status_code = 403 - response.reason = storeapi.constants.MISSING_NAMESPACE - content = { - "error_list": [ - { - "message": storeapi.constants.MISSING_NAMESPACE, - "extra": {"url": "http://fake-url.com", "api": "fake-api"}, - } - ] - } - response.json.return_value = content - self.fake_store_account_info.mock.side_effect = ( - storeapi.errors.StoreAccountInformationError(response) - ) - - with pytest.raises(storeapi.errors.NeedTermsSignedError) as exc_info: - self.run_command(["login"], input="user@example.com\nsecret\n") - - assert ( - str(exc_info.value) - == "Developer Terms of Service agreement must be signed before continuing: You need to set a username. It will appear in the developer field alongside the other details for your snap. Please visit http://fake-url.com and login again." - ) - - def test_failed_login_with_unexpected_account_error(self): - # Test to simulate get_account_info raising unexpected errors. - response = mock.Mock() - response.status_code = 500 - response.reason = "Internal Server Error" - content = { - "error_list": [ - { - "message": "Just another error", - "extra": {"url": "http://fake-url.com", "api": "fake-api"}, - } - ] - } - response.json.return_value = content - self.fake_store_account_info.mock.side_effect = ( - storeapi.errors.StoreAccountInformationError(response) - ) - - with pytest.raises(storeapi.errors.StoreAccountInformationError) as exc_info: - self.run_command(["login"], input="user@example.com\nsecret\n\n") - - assert ( - str(exc_info.value) - == "Error fetching account information from store: Just another error" - ) - - def test_failed_login_with_dev_agreement_error_with_choice_no(self): - response = mock.Mock() - response.status_code = 403 - response.reason = storeapi.constants.MISSING_AGREEMENT - content = { - "error_list": [ - { - "message": storeapi.constants.MISSING_AGREEMENT, - "extra": {"url": "http://fake-url.com", "api": "fake-api"}, - } - ] - } - response.json.return_value = content - self.fake_store_account_info.mock.side_effect = ( - storeapi.errors.StoreAccountInformationError(response) - ) - - with pytest.raises(storeapi.errors.NeedTermsSignedError) as exc_info: - self.run_command(["login"], input="user@example.com\nsecret\nn\n") - - assert ( - str(exc_info.value) - == "Developer Terms of Service agreement must be signed before continuing: You must agree to the developer terms and conditions to upload snaps." - ) - - def test_failed_login_with_dev_agreement_error_with_choice_yes(self): - response = mock.Mock() - response.status_code = 403 - response.reason = storeapi.constants.MISSING_AGREEMENT - content = { - "error_list": [ - { - "message": storeapi.constants.MISSING_AGREEMENT, - "extra": {"url": "http://fake-url.com", "api": "fake-api"}, - } - ] - } - response.json.return_value = content - self.fake_store_account_info.mock.side_effect = ( - storeapi.errors.StoreAccountInformationError(response) - ) - - with pytest.raises(storeapi.errors.NeedTermsSignedError) as exc_info: - self.run_command(["login"], input="user@example.com\nsecret\ny\n") - - assert ( - str(exc_info.value) - == "Developer Terms of Service agreement must be signed before continuing: Unexpected error encountered during signing the developer terms and conditions. Please visit http://fake-url.com and agree to the terms and conditions before continuing." - ) diff --git a/tests/unit/commands/test_manage.py b/tests/unit/commands/test_manage.py new file mode 100644 index 0000000000..7f415ab283 --- /dev/null +++ b/tests/unit/commands/test_manage.py @@ -0,0 +1,178 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +from unittest.mock import ANY, call + +import pytest + +from snapcraft import commands, errors + +############ +# Fixtures # +############ + + +@pytest.fixture +def fake_store_release(mocker): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.release", + autospec=True, + ) + return fake_client + + +@pytest.fixture +def fake_store_close(mocker): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.close", + autospec=True, + ) + return fake_client + + +@pytest.fixture +def fake_store_get_account_info(mocker): + # reduced payload + data = { + "snaps": { + "16": { + "test-snap": { + "snap-id": "12345678", + }, + } + } + } + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.get_account_info", + autospec=True, + return_value=data, + ) + return fake_client + + +################### +# Release Command # +################### + + +@pytest.mark.usefixtures("memory_keyring") +def test_release(emitter, fake_store_release): + cmd = commands.StoreReleaseCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", revision=10, channels="edge", progressive_percentage=None + ) + ) + + assert fake_store_release.mock_calls == [ + call( + ANY, + snap_name="test-snap", + revision=10, + channels=["edge"], + progressive_percentage=None, + ) + ] + emitter.assert_message("Released 'test-snap' revision 10 to channels: 'edge'") + + +@pytest.mark.usefixtures("memory_keyring") +def test_release_multiple_channels(emitter, fake_store_release): + cmd = commands.StoreReleaseCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + revision=10, + channels="edge,latest/stable,1.0/beta", + progressive_percentage=None, + ) + ) + + assert fake_store_release.mock_calls == [ + call( + ANY, + snap_name="test-snap", + revision=10, + channels=["edge", "latest/stable", "1.0/beta"], + progressive_percentage=None, + ) + ] + emitter.assert_message( + "Released 'test-snap' revision 10 to channels: '1.0/beta', 'edge', and 'latest/stable'" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_release_progressive(emitter, fake_store_release): + cmd = commands.StoreReleaseCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", revision=10, channels="edge", progressive_percentage=10 + ) + ) + + assert fake_store_release.mock_calls == [ + call( + ANY, + snap_name="test-snap", + revision=10, + channels=["edge"], + progressive_percentage=10, + ) + ] + emitter.assert_message("Released 'test-snap' revision 10 to channels: 'edge'") + + +################# +# Close Command # +################# + + +@pytest.mark.usefixtures("memory_keyring", "fake_store_get_account_info") +def test_close(emitter, fake_store_close): + cmd = commands.StoreCloseCommand(None) + + cmd.run(argparse.Namespace(name="test-snap", channel="edge")) + + assert fake_store_close.mock_calls == [ + call( + ANY, + snap_id="12345678", + channel="edge", + ) + ] + emitter.assert_message("Channel 'edge' for 'test-snap' is now closed") + + +@pytest.mark.usefixtures("memory_keyring", "fake_store_get_account_info") +def test_close_no_snap_id(emitter): + cmd = commands.StoreCloseCommand(None) + + with pytest.raises(errors.SnapcraftError) as raised: + cmd.run(argparse.Namespace(name="test-unknown-snap", channel="edge")) + + assert str(raised.value) == ( + "'test-unknown-snap' not found or not owned by this account" + ) + + emitter.assert_trace( + "KeyError('test-unknown-snap') no found in " + "{'snaps': {'16': {'test-snap': {'snap-id': '12345678'}}}}" + ) diff --git a/tests/unit/commands/test_names.py b/tests/unit/commands/test_names.py new file mode 100644 index 0000000000..f16d51c979 --- /dev/null +++ b/tests/unit/commands/test_names.py @@ -0,0 +1,238 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +from textwrap import dedent +from unittest.mock import ANY, call + +import pytest + +from snapcraft import commands + +############ +# Fixtures # +############ + + +@pytest.fixture +def fake_store_register(mocker): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.register", + autospec=True, + ) + return fake_client + + +@pytest.fixture +def fake_store_get_account_info(mocker): + # reduced payload + data = { + "snaps": { + "16": { + "test-snap-public": { + "private": False, + "since": "2016-07-26T20:18:32Z", + "status": "Approved", + }, + "test-snap-private": { + "private": True, + "since": "2016-07-26T20:18:32Z", + "status": "Approved", + }, + "test-snap-not-approved": { + "private": False, + "since": "2016-07-26T20:18:32Z", + "status": "Dispute", + }, + } + } + } + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.get_account_info", + autospec=True, + return_value=data, + ) + return fake_client + + +#################### +# Register Command # +#################### + + +@pytest.mark.usefixtures("memory_keyring") +def test_register_default(emitter, fake_confirmation_prompt, fake_store_register): + fake_confirmation_prompt.return_value = True + + cmd = commands.StoreRegisterCommand(None) + + cmd.run( + argparse.Namespace( + store_id=None, private=False, yes=False, **{"snap-name": "test-snap"} + ) + ) + + assert fake_store_register.mock_calls == [ + call(ANY, "test-snap", is_private=False, store_id=None) + ] + emitter.assert_message("Registered 'test-snap'") + assert fake_confirmation_prompt.mock_calls == [ + call( + dedent( + """\ + We always want to ensure that users get the software they expect + for a particular name. + + If needed, we will rename snaps to ensure that a particular name + reflects the software most widely expected by our community. + + For example, most people would expect 'thunderbird' to be published by + Mozilla. They would also expect to be able to get other snaps of + Thunderbird as '$username-thunderbird'. + + Would you say that MOST users will expect 'test-snap' to come from + you, and be the software you intend to publish there?""" + ) + ) + ] + + +@pytest.mark.usefixtures("memory_keyring") +def test_register_yes(emitter, fake_store_register): + cmd = commands.StoreRegisterCommand(None) + + cmd.run( + argparse.Namespace( + store_id=None, private=False, yes=True, **{"snap-name": "test-snap"} + ) + ) + + assert fake_store_register.mock_calls == [ + call(ANY, "test-snap", is_private=False, store_id=None) + ] + emitter.assert_message("Registered 'test-snap'") + + +@pytest.mark.usefixtures("memory_keyring") +def test_register_no(emitter, fake_confirmation_prompt, fake_store_register): + cmd = commands.StoreRegisterCommand(None) + + cmd.run( + argparse.Namespace( + store_id=None, private=False, yes=False, **{"snap-name": "test-snap"} + ) + ) + + assert fake_store_register.mock_calls == [] + emitter.assert_messages(["Snap name 'test-snap' not registered"]) + assert fake_confirmation_prompt.mock_calls == [ + call( + dedent( + """\ + We always want to ensure that users get the software they expect + for a particular name. + + If needed, we will rename snaps to ensure that a particular name + reflects the software most widely expected by our community. + + For example, most people would expect 'thunderbird' to be published by + Mozilla. They would also expect to be able to get other snaps of + Thunderbird as '$username-thunderbird'. + + Would you say that MOST users will expect 'test-snap' to come from + you, and be the software you intend to publish there?""" + ) + ) + ] + + +@pytest.mark.usefixtures("memory_keyring", "fake_confirmation_prompt") +def test_register_private(emitter, fake_store_register): + cmd = commands.StoreRegisterCommand(None) + + cmd.run( + argparse.Namespace( + store_id=None, private=True, yes=False, **{"snap-name": "test-snap"} + ) + ) + + assert fake_store_register.mock_calls == [] + emitter.assert_message( + dedent( + """\ + Even though this is private snap, you should think carefully about + the choice of name and make sure you are confident nobody else will + have a stronger claim to that particular name. If you are unsure + then we suggest you prefix the name with your developer identity, + As '$username-yoyodyne-www-site-content'.""" + ), + intermediate=True, + ) + emitter.assert_message( + "Snap name 'test-snap' not registered", + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_register_store_id(emitter, fake_store_register): + cmd = commands.StoreRegisterCommand(None) + + cmd.run( + argparse.Namespace( + store_id="1234", private=False, yes=True, **{"snap-name": "test-snap"} + ) + ) + + assert fake_store_register.mock_calls == [ + call(ANY, "test-snap", is_private=False, store_id="1234") + ] + emitter.assert_message("Registered 'test-snap'") + + +################# +# Names Command # +################# + + +@pytest.mark.parametrize( + "command_class", + [ + commands.StoreNamesCommand, + commands.StoreLegacyListCommand, + commands.StoreLegacyListRegisteredCommand, + ], +) +@pytest.mark.usefixtures("memory_keyring") +def test_names(emitter, fake_store_get_account_info, command_class): + cmd = command_class(None) + + cmd.run( + argparse.Namespace( + store_id="1234", private=False, yes=True, **{"snap-name": "test-snap"} + ) + ) + + assert fake_store_get_account_info.mock_calls == [call(ANY)] + if command_class.hidden: + emitter.assert_progress("This command is deprecated: use 'names' instead") + emitter.assert_message( + dedent( + """\ + Name Since Visibility Notes + test-snap-private 2016-07-26T20:18:32Z private - + test-snap-public 2016-07-26T20:18:32Z public -""" + ) + ) diff --git a/tests/unit/commands/test_register.py b/tests/unit/commands/test_register.py deleted file mode 100644 index 31a48542fa..0000000000 --- a/tests/unit/commands/test_register.py +++ /dev/null @@ -1,132 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2016-2017 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from unittest import mock - -from simplejson.scanner import JSONDecodeError -from testtools.matchers import Contains, Equals, Not - -from snapcraft import storeapi - -from . import FakeStoreCommandsBaseTestCase - - -class RegisterTestCase(FakeStoreCommandsBaseTestCase): - def test_register_without_name_must_error(self): - result = self.run_command(["register"]) - - self.assertThat(result.exit_code, Equals(2)) - self.assertThat(result.output, Contains("Usage:")) - - def test_register_without_login_must_ask(self): - self.fake_store_register.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), - None, - ] - - result = self.run_command( - ["register", "snap-name"], input="y\nuser@example.com\nsecret\n" - ) - self.assertThat( - result.output, Contains("You are required to login before continuing.") - ) - - def test_register_name_successfully(self): - result = self.run_command(["register", "test-snap"], input="y\n") - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Registering test-snap")) - self.assertThat( - result.output, - Contains("Congrats! You are now the publisher of 'test-snap'."), - ) - self.assertThat( - result.output, - Not(Contains("Congratulations! You're now the publisher for 'test-snap'.")), - ) - self.fake_store_register.mock.assert_called_once_with( - "test-snap", is_private=False, series="16", store_id=None - ) - - def test_register_name_to_specific_store_successfully(self): - result = self.run_command( - ["register", "test-snap", "--store", "my-brand"], input="y\n" - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Registering test-snap")) - self.assertThat( - result.output, - Contains("Congrats! You are now the publisher of 'test-snap'."), - ) - self.assertThat( - result.output, - Not(Contains("Congratulations! You're now the publisher for 'test-snap'.")), - ) - self.fake_store_register.mock.assert_called_once_with( - "test-snap", is_private=False, series="16", store_id="my-brand" - ) - - def test_register_private_name_successfully(self): - result = self.run_command(["register", "test-snap", "--private"], input="y\n") - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains("Even though this is private snap, you should think carefully"), - ) - self.assertThat(result.output, Contains("Registering test-snap")) - self.assertThat( - result.output, - Contains("Congrats! You are now the publisher of 'test-snap'."), - ) - self.assertThat( - result.output, - Not(Contains("Congratulations! You're now the publisher for 'test-snap'.")), - ) - self.fake_store_register.mock.assert_called_once_with( - "test-snap", is_private=True, series="16", store_id=None - ) - - def test_registration_failed(self): - response = mock.Mock() - response.json.side_effect = JSONDecodeError("mock-fail", "doc", 1) - self.fake_store_register.mock.side_effect = ( - storeapi.errors.StoreRegistrationError("test-snap", response) - ) - - raised = self.assertRaises( - storeapi.errors.StoreRegistrationError, - self.run_command, - ["register", "test-snap"], - input="y\n", - ) - - self.assertThat(str(raised), Equals("Registration failed.")) - - def test_registration_cancelled(self): - response = mock.Mock() - response.json.side_effect = JSONDecodeError("mock-fail", "doc", 1) - self.fake_store_register.mock.side_effect = ( - storeapi.errors.StoreRegistrationError("test-snap", response) - ) - - result = self.run_command(["register", "test-snap"], input="n\n") - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, Contains("Thank you! 'test-snap' will remain available") - ) diff --git a/tests/unit/commands/test_release.py b/tests/unit/commands/test_release.py deleted file mode 100644 index 3c14549a36..0000000000 --- a/tests/unit/commands/test_release.py +++ /dev/null @@ -1,281 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2016-2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from textwrap import dedent - -from testtools.matchers import Contains, Equals - -from snapcraft import storeapi -from snapcraft.storeapi.v2.channel_map import ( - MappedChannel, - Progressive, - Revision, - SnapChannel, -) - -from . import FakeStoreCommandsBaseTestCase - - -class ReleaseCommandTestCase(FakeStoreCommandsBaseTestCase): - def setUp(self): - super().setUp() - - self.fake_store_release.mock.return_value = {"opened_channels": ["2.1/beta"]} - - def test_release_without_snap_name_must_raise_exception(self): - result = self.run_command(["release"]) - - self.assertThat(result.exit_code, Equals(2)) - self.assertThat(result.output, Contains("Usage:")) - - def test_release(self): - result = self.run_command(["release", "nil-snap", "19", "2.1/beta"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 amd64 stable - - - candidate - - - beta 10 19 - edge ↑ ↑ - The '2.1/beta' channel is now open. - """ - ) - ), - ) - self.fake_store_release.mock.assert_called_once_with( - snap_name="nil-snap", - revision="19", - channels=["2.1/beta"], - progressive_percentage=None, - ) - - def test_progressive_release(self): - self.channel_map.channel_map[0].progressive.percentage = 10.0 - self.channel_map.channel_map[0].progressive.current_percentage = 5.0 - - result = self.run_command( - [ - "release", - "nil-snap", - "19", - "2.1/beta", - "--progressive", - "10", - "--experimental-progressive-releases", - ] - ) - - self.assertThat( - result.output, - Equals( - dedent( - """\ - *EXPERIMENTAL* progressive releases in use. - Track Arch Channel Version Revision Progress - 2.1 amd64 stable - - - - candidate - - - - beta - - - - 10 19 5→10% - edge ↑ ↑ - - The '2.1/beta' channel is now open. - """ - ) - ), - ) - self.fake_store_release.mock.assert_called_once_with( - snap_name="nil-snap", - revision="19", - channels=["2.1/beta"], - progressive_percentage=10, - ) - - def test_release_with_branch(self): - self.fake_store_release.mock.return_value = { - "opened_channels": ["stable/hotfix1"] - } - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/stable/hotfix1", - architecture="amd64", - expiration_date="2020-02-03T20:58:37Z", - revision=20, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["amd64"], revision=20, version="10hotfix") - ) - self.channel_map.snap.channels.append( - SnapChannel( - name="2.1/stable/hotfix1", - track="2.1", - risk="stable", - branch="hotfix1", - fallback="2.1/stable", - ) - ) - - result = self.run_command(["release", "nil-snap", "20", "2.1/stable/hotfix1"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision Expires at - 2.1 amd64 stable - - - stable/hotfix1 10hotfix 20 2020-02-03T20:58:37Z - candidate - - - beta 10 19 - edge ↑ ↑ - The 'stable/hotfix1' channel is now open. - """ - ) - ), - ) - - self.fake_store_release.mock.assert_called_once_with( - snap_name="nil-snap", - revision="20", - channels=["2.1/stable/hotfix1"], - progressive_percentage=None, - ) - - def test_progressive_release_with_branch(self): - self.fake_store_release.mock.return_value = { - "opened_channels": ["2.1/stable/hotfix1"] - } - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/stable/hotfix1", - architecture="amd64", - expiration_date="2020-02-03T20:58:37Z", - revision=20, - progressive=Progressive( - paused=None, percentage=80.0, current_percentage=None - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["amd64"], revision=20, version="10hotfix") - ) - self.channel_map.snap.channels.append( - SnapChannel( - name="2.1/stable/hotfix1", - track="2.1", - risk="stable", - branch="hotfix1", - fallback="2.1/stable", - ) - ) - - result = self.run_command( - [ - "release", - "--progressive", - "80", - "--experimental-progressive-releases", - "nil-snap", - "20", - "2.1/stable/hotfix1", - ] - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - *EXPERIMENTAL* progressive releases in use. - Track Arch Channel Version Revision Progress Expires at - 2.1 amd64 stable - - - - stable/hotfix1 10hotfix 20 ?→80% 2020-02-03T20:58:37Z - candidate - - - - beta 10 19 - - edge ↑ ↑ - - The '2.1/stable/hotfix1' channel is now open. - """ - ) - ), - ) - self.fake_store_release.mock.assert_called_once_with( - snap_name="nil-snap", - revision="20", - channels=["2.1/stable/hotfix1"], - progressive_percentage=80, - ) - - def test_progressive_release_with_null_current_percentage(self): - self.channel_map.channel_map[0].progressive.percentage = 10.0 - self.channel_map.channel_map[0].progressive.current_percentage = None - - result = self.run_command( - [ - "release", - "nil-snap", - "19", - "2.1/beta", - "--progressive", - "10", - "--experimental-progressive-releases", - ] - ) - - self.assertThat( - result.output, - Equals( - dedent( - """\ - *EXPERIMENTAL* progressive releases in use. - Track Arch Channel Version Revision Progress - 2.1 amd64 stable - - - - candidate - - - - beta - - - - 10 19 ?→10% - edge ↑ ↑ - - The '2.1/beta' channel is now open. - """ - ) - ), - ) - self.fake_store_release.mock.assert_called_once_with( - snap_name="nil-snap", - revision="19", - channels=["2.1/beta"], - progressive_percentage=10, - ) - - def test_release_without_login_must_ask(self): - self.fake_store_release.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), - {"opened_channels": ["beta"]}, - ] - - result = self.run_command( - ["release", "nil-snap", "19", "beta"], input="user@example.com\nsecret\n" - ) - self.assertThat( - result.output, Contains("You are required to login before continuing.") - ) diff --git a/tests/unit/commands/test_status.py b/tests/unit/commands/test_status.py index 2f45e2e48f..ce580fda0f 100644 --- a/tests/unit/commands/test_status.py +++ b/tests/unit/commands/test_status.py @@ -1,468 +1,605 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2016-2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from textwrap import dedent - -from testtools.matchers import Contains, Equals - -from snapcraft import storeapi -from snapcraft.storeapi.v2.channel_map import ( - MappedChannel, - Progressive, - Revision, - SnapChannel, -) - -from . import FakeStoreCommandsBaseTestCase - - -class StatusCommandTestCase(FakeStoreCommandsBaseTestCase): - def test_status_without_snap_raises_exception(self): - result = self.run_command(["status"]) - - self.assertThat(result.exit_code, Equals(2)) - self.assertThat(result.output, Contains("Usage:")) - - def test_status_without_login_must_ask(self): - self.fake_store_get_snap_channel_map.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), - self.channel_map, - ] - - result = self.run_command( - ["status", "snap-test"], input="user@example.com\nsecret\n" - ) - self.assertThat( - result.output, Contains("You are required to login before continuing.") - ) - - def test_status(self): - result = self.run_command(["status", "snap-test"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 amd64 stable - - - candidate - - - beta 10 19 - edge ↑ ↑ - 2.0 amd64 stable - - - candidate - - - beta 10 18 - edge ↑ ↑ - """ - ) +import argparse + +import pytest + +from snapcraft import commands +from snapcraft.commands.store import channel_map + +############ +# Fixtures # +############ + + +@pytest.fixture +def channel_map_result(): + return channel_map.ChannelMap.unmarshal( + { + "channel-map": [ + { + "architecture": "amd64", + "channel": "2.1/beta", + "expiration-date": None, + "revision": 19, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + }, + { + "architecture": "amd64", + "channel": "2.0/beta", + "expiration-date": None, + "revision": 18, + "progressive": { + "paused": None, + "percentage": None, + "current-percentage": None, + }, + }, + ], + "revisions": [ + {"architectures": ["amd64"], "revision": 19, "version": "10"}, + {"architectures": ["amd64"], "revision": 18, "version": "10"}, + ], + "snap": { + "name": "snap-test", + "channels": [ + { + "branch": None, + "fallback": None, + "name": "2.1/stable", + "risk": "stable", + "track": "2.1", + }, + { + "branch": None, + "fallback": "2.1/stable", + "name": "2.1/candidate", + "risk": "candidate", + "track": "2.1", + }, + { + "branch": None, + "fallback": "2.1/candidate", + "name": "2.1/beta", + "risk": "beta", + "track": "2.1", + }, + { + "branch": None, + "fallback": "2.1/beta", + "name": "2.1/edge", + "risk": "edge", + "track": "2.1", + }, + { + "branch": None, + "fallback": None, + "name": "2.0/stable", + "risk": "stable", + "track": "2.0", + }, + { + "branch": None, + "fallback": "2.0/stable", + "name": "2.0/candidate", + "risk": "candidate", + "track": "2.0", + }, + { + "branch": None, + "fallback": "2.0/candidate", + "name": "2.0/beta", + "risk": "beta", + "track": "2.0", + }, + { + "branch": None, + "fallback": "2.0/beta", + "name": "2.0/edge", + "risk": "edge", + "track": "2.0", + }, + ], + "default-track": "2.1", + "tracks": [ + { + "name": "2.0", + "status": "default", + "creation-date": "2019-10-17T14:11:59Z", + "version-pattern": "2\\.*", + }, + { + "name": "latest", + "status": "active", + "creation-date": None, + "version-pattern": None, + }, + ], + }, + } + ) + + +@pytest.fixture +def fake_store_get_status_map(mocker, channel_map_result): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.get_channel_map", + autospec=True, + return_value=channel_map_result, + ) + return fake_client + + +################## +# Status Command # +################## + + +@pytest.mark.usefixtures("memory_keyring", "fake_store_get_status_map") +def test_default(emitter): + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 19 -\n" + " edge ↑ ↑ -\n" + "2.0 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 18 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_following(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map = [ + channel_map.MappedChannel( + channel="2.1/stable", + architecture="amd64", + expiration_date="2020-02-03T20:58:37Z", + revision=20, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None ), ) + ] + channel_map_result.revisions.append( + channel_map.Revision(architectures=["amd64"], revision=20, version="10") + ) + fake_store_get_status_map.return_value = channel_map_result - def test_status_following(self): - self.channel_map.channel_map = [ - MappedChannel( - channel="2.1/stable", - architecture="amd64", - expiration_date="2020-02-03T20:58:37Z", - revision=20, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ] - self.channel_map.revisions.append( - Revision(architectures=["amd64"], revision=20, version="10") - ) + cmd = commands.StoreStatusCommand(None) - result = self.run_command(["status", "snap-test"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 amd64 stable 10 20 - candidate ↑ ↑ - beta ↑ ↑ - edge ↑ ↑ - """ - ) - ), + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, ) + ) - def test_status_no_releases(self): - self.channel_map.channel_map = [] + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 amd64 stable 10 20 -\n" + " candidate ↑ ↑ -\n" + " beta ↑ ↑ -\n" + " edge ↑ ↑ -" + "" + ) - result = self.run_command(["status", "snap-test"]) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output.strip(), Equals("This snap has no released revisions.") - ) +@pytest.mark.usefixtures("memory_keyring") +def test_no_releases(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map = [] - def test_progressive_status(self): - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/beta", - architecture="amd64", - expiration_date="2020-02-03T20:58:37Z", - revision=20, - progressive=Progressive( - paused=None, percentage=10.0, current_percentage=7.2 - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["amd64"], revision=20, version="11") - ) + cmd = commands.StoreStatusCommand(None) - result = self.run_command( - ["status", "snap-test", "--experimental-progressive-releases"] + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, ) + ) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - *EXPERIMENTAL* progressive releases in use. - Track Arch Channel Version Revision Progress - 2.1 amd64 stable - - - - candidate - - - - beta 10 19 93→90% - 11 20 7→10% - edge ↑ ↑ - - 2.0 amd64 stable - - - - candidate - - - - beta 10 18 - - edge ↑ ↑ - - """ - ) - ), - ) + emitter.assert_message("This snap has no released revisions") - def test_status_by_arch(self): - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/beta", - architecture="s390x", - expiration_date=None, - revision=99, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["s390x"], revision=99, version="10") - ) - result = self.run_command(["status", "snap-test", "--arch=s390x"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 s390x stable - - - candidate - - - beta 10 99 - edge ↑ ↑ - """ - ) +@pytest.mark.usefixtures("memory_keyring") +def test_progressive(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/beta", + architecture="amd64", + expiration_date="2020-02-03T20:58:37Z", + revision=20, + progressive=channel_map.Progressive( + paused=None, percentage=10.0, current_percentage=7.2 ), ) - - def test_status_by_multiple_arch(self): - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/beta", - architecture="s390x", - expiration_date=None, - revision=98, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/beta", - architecture="arm64", - expiration_date=None, - revision=99, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["s390x"], revision=98, version="10") - ) - self.channel_map.revisions.append( - Revision(architectures=["arm64"], revision=99, version="10") - ) - - result = self.run_command( - ["status", "snap-test", "--arch=s390x", "--arch=arm64"] - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 arm64 stable - - - candidate - - - beta 10 99 - edge ↑ ↑ - s390x stable - - - candidate - - - beta 10 98 - edge ↑ ↑ - """ - ) + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["amd64"], revision=20, version="11") + ) + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 19 93→90%\n" + " 11 20 7→10%\n" + " edge ↑ ↑ -\n" + "2.0 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 18 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_arch(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/beta", + architecture="s390x", + expiration_date=None, + revision=99, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None ), ) - - def test_status_by_track(self): - result = self.run_command(["status", "snap-test", "--track=2.0"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.0 amd64 stable - - - candidate - - - beta 10 18 - edge ↑ ↑ - """ - ) + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["s390x"], revision=99, version="10") + ) + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=["s390x"], + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 s390x stable - - -\n" + " candidate - - -\n" + " beta 10 99 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_multiple_arch(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/beta", + architecture="s390x", + expiration_date=None, + revision=98, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None ), ) - - def test_status_by_multiple_track(self): - result = self.run_command(["status", "snap-test", "--track=2.0", "--track=2.1"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.1 amd64 stable - - - candidate - - - beta 10 19 - edge ↑ ↑ - 2.0 amd64 stable - - - candidate - - - beta 10 18 - edge ↑ ↑ - """ - ) + ) + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/beta", + architecture="arm64", + expiration_date=None, + revision=99, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None ), ) - - def test_status_by_track_and_arch(self): - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/beta", - architecture="s390x", - expiration_date=None, - revision=99, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.channel_map.append( - MappedChannel( - channel="2.0/beta", - architecture="s390x", - expiration_date=None, - revision=98, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["s390x"], revision=99, version="10") - ) - self.channel_map.revisions.append( - Revision(architectures=["s390x"], revision=98, version="10") - ) - - result = self.run_command( - ["status", "snap-test", "--arch=s390x", "--track=2.0"] - ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision - 2.0 s390x stable - - - candidate - - - beta 10 98 - edge ↑ ↑ - """ - ) + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["s390x"], revision=98, version="10") + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["arm64"], revision=99, version="10") + ) + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=["s390x", "arm64"], + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 arm64 stable - - -\n" + " candidate - - -\n" + " beta 10 99 -\n" + " edge ↑ ↑ -\n" + " s390x stable - - -\n" + " candidate - - -\n" + " beta 10 98 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_track(emitter, fake_store_get_status_map): + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=["2.0"], + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.0 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 18 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_multi_track(emitter, fake_store_get_status_map): + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=["2.0", "2.1"], + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 19 -\n" + " edge ↑ ↑ -\n" + "2.0 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 18 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_arch_and_track(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/beta", + architecture="s390x", + expiration_date=None, + revision=99, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None ), ) - - def test_status_including_branch(self): - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/stable/hotfix1", - architecture="amd64", - expiration_date="2020-02-03T20:58:37Z", - revision=20, - progressive=Progressive( - paused=None, percentage=None, current_percentage=None - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["amd64"], revision=20, version="10hotfix") - ) - self.channel_map.snap.channels.append( - SnapChannel( - name="2.1/stable/hotfix1", - track="2.1", - risk="stable", - branch="hotfix1", - fallback="2.1/stable", - ) - ) - - result = self.run_command(["status", "snap-test"]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - Track Arch Channel Version Revision Expires at - 2.1 amd64 stable - - - stable/hotfix1 10hotfix 20 2020-02-03T20:58:37Z - candidate - - - beta 10 19 - edge ↑ ↑ - 2.0 amd64 stable - - - candidate - - - beta 10 18 - edge ↑ ↑ - """ - ) + ) + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.0/beta", + architecture="s390x", + expiration_date=None, + revision=98, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None ), ) - - def test_progressive_status_including_branch(self): - self.channel_map.channel_map.append( - MappedChannel( - channel="2.1/stable/hotfix1", - architecture="amd64", - expiration_date="2020-02-03T20:58:37Z", - revision=20, - progressive=Progressive( - paused=None, percentage=20.0, current_percentage=12.3 - ), - ) - ) - self.channel_map.revisions.append( - Revision(architectures=["amd64"], revision=20, version="10hotfix") - ) - self.channel_map.snap.channels.append( - SnapChannel( - name="2.1/stable/hotfix1", - track="2.1", - risk="stable", - branch="hotfix1", - fallback="2.1/stable", - ) - ) - - result = self.run_command( - ["status", "snap-test", "--experimental-progressive-releases"] + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["s390x"], revision=98, version="10") + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["s390x"], revision=99, version="10") + ) + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=["s390x"], + track=["2.1"], + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 s390x stable - - -\n" + " candidate - - -\n" + " beta 10 99 -\n" + " edge ↑ ↑ -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_branch(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/stable/hotfix1", + architecture="amd64", + expiration_date="2020-02-03T20:58:37Z", + revision=20, + progressive=channel_map.Progressive( + paused=None, percentage=None, current_percentage=None + ), ) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - *EXPERIMENTAL* progressive releases in use. - Track Arch Channel Version Revision Progress Expires at - 2.1 amd64 stable - - - - stable/hotfix1 10hotfix 20 12→20% 2020-02-03T20:58:37Z - candidate - - - - beta 10 19 - - edge ↑ ↑ - - 2.0 amd64 stable - - - - candidate - - - - beta 10 18 - - edge ↑ ↑ - - """ - ) + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["am64"], revision=20, version="10hotfix") + ) + channel_map_result.snap.channels.append( + channel_map.SnapChannel( + name="2.1/stable/hotfix1", + track="2.1", + risk="stable", + branch="hotfix1", + fallback="2.1/stable", + ) + ) + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress Expires at\n" + "2.1 amd64 stable - - - -\n" + " stable/hotfix1 10hotfix 20 - 2020-02-03T20:58:37Z\n" + " candidate - - - -\n" + " beta 10 19 - -\n" + " edge ↑ ↑ - -\n" + "2.0 amd64 stable - - - -\n" + " candidate - - - -\n" + " beta 10 18 - -\n" + " edge ↑ ↑ - -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_progressive_branch(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map.append( + channel_map.MappedChannel( + channel="2.1/stable/hotfix1", + architecture="amd64", + expiration_date="2020-02-03T20:58:37Z", + revision=20, + progressive=channel_map.Progressive( + paused=None, percentage=20.0, current_percentage=12.3 ), ) + ) + channel_map_result.revisions.append( + channel_map.Revision(architectures=["am64"], revision=20, version="10hotfix") + ) + channel_map_result.snap.channels.append( + channel_map.SnapChannel( + name="2.1/stable/hotfix1", + track="2.1", + risk="stable", + branch="hotfix1", + fallback="2.1/stable", + ) + ) + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress Expires at\n" + "2.1 amd64 stable - - - -\n" + " stable/hotfix1 10hotfix 20 12→20% 2020-02-03T20:58:37Z\n" + " candidate - - - -\n" + " beta 10 19 - -\n" + " edge ↑ ↑ - -\n" + "2.0 amd64 stable - - - -\n" + " candidate - - - -\n" + " beta 10 18 - -\n" + " edge ↑ ↑ - -" + ) + + +@pytest.mark.usefixtures("memory_keyring") +def test_progressive_unknown(emitter, fake_store_get_status_map, channel_map_result): + channel_map_result.channel_map[0].progressive.percentage = 10.0 + channel_map_result.channel_map[0].progressive.current_percentage = None + fake_store_get_status_map.return_value = channel_map_result + + cmd = commands.StoreStatusCommand(None) + + cmd.run( + argparse.Namespace( + name="test-snap", + arch=None, + track=None, + ) + ) + + emitter.assert_message( + "Track Arch Channel Version Revision Progress\n" + "2.1 amd64 stable - - -\n" + " candidate - - -\n" + " beta - - -\n" + " 10 19 ?→10%\n" + " edge ↑ ↑ -\n" + "2.0 amd64 stable - - -\n" + " candidate - - -\n" + " beta 10 18 -\n" + " edge ↑ ↑ -" + ) + + +####################### +# List Tracks Command # +####################### + + +@pytest.mark.parametrize( + "command_class", + [ + commands.StoreListTracksCommand, + commands.StoreTracksCommand, + ], +) +@pytest.mark.usefixtures("memory_keyring", "fake_store_get_status_map") +def test_list_tracks(emitter, command_class): + cmd = command_class(None) - def test_progressive_status_with_null_current_percentage(self): - self.channel_map.channel_map[0].progressive.percentage = 10.0 - self.channel_map.channel_map[0].progressive.current_percentage = None - - result = self.run_command( - ["status", "snap-test", "--experimental-progressive-releases"] - ) + cmd.run(argparse.Namespace(name="test-snap")) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Equals( - dedent( - """\ - *EXPERIMENTAL* progressive releases in use. - Track Arch Channel Version Revision Progress - 2.1 amd64 stable - - - - candidate - - - - beta - - - - 10 19 ?→10% - edge ↑ ↑ - - 2.0 amd64 stable - - - - candidate - - - - beta 10 18 - - edge ↑ ↑ - - """ - ) - ), - ) + emitter.assert_message( + "Name Status Creation-Date Version-Pattern\n" + "latest active - -\n" + "2.0 default 2019-10-17T14:11:59Z 2\\.*" + ) diff --git a/tests/unit/commands/test_upload.py b/tests/unit/commands/test_upload.py index a7757c8ac5..5e71ad4f38 100644 --- a/tests/unit/commands/test_upload.py +++ b/tests/unit/commands/test_upload.py @@ -1,565 +1,132 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2016-2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import os -from unittest import mock - -import fixtures -from testtools.matchers import Contains, Equals, FileExists, Not -from xdg import BaseDirectory - -import tests -from snapcraft import file_utils, internal, storeapi -from snapcraft.internal import review_tools -from snapcraft.storeapi.errors import ( - StoreDeltaApplicationError, - StoreUpDownError, - StoreUploadError, -) - -from . import FakeStoreCommandsBaseTestCase - - -class UploadCommandBaseTestCase(FakeStoreCommandsBaseTestCase): - def setUp(self): - super().setUp() - - self.snap_file = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap.snap" - ) - - self.fake_review_tools_run = fixtures.MockPatch( - "snapcraft.internal.review_tools.run" - ) - self.useFixture(self.fake_review_tools_run) - - self.fake_review_tools_is_available = fixtures.MockPatch( - "snapcraft.internal.review_tools.is_available", return_value=False - ) - self.useFixture(self.fake_review_tools_is_available) - - -class UploadCommandTestCase(UploadCommandBaseTestCase): - def test_upload_without_snap_must_raise_exception(self): - result = self.run_command(["upload"]) - - self.assertThat(result.exit_code, Equals(2)) - self.assertThat(result.output, Contains("Usage:")) - - def test_upload_a_snap(self): - # Upload - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Revision 19 of 'basic' created.")) - self.fake_store_upload.mock.assert_called_once_with( - snap_name="basic", - snap_filename=self.snap_file, - built_at=None, +import argparse +import pathlib +from unittest.mock import ANY, call + +import craft_cli.errors +import pytest + +from snapcraft import commands +from tests import unit + +############ +# Fixtures # +############ + + +@pytest.fixture(autouse=True) +def fake_store_client_upload_file(mocker): + fake_client = mocker.patch( + "craft_store.BaseClient.upload_file", + autospec=True, + return_value="2ecbfac1-3448-4e7d-85a4-7919b999f120", + ) + return fake_client + + +@pytest.fixture +def fake_store_notify_upload(mocker): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.notify_upload", + autospec=True, + return_value=10, + ) + return fake_client + + +@pytest.fixture +def fake_store_verify_upload(mocker): + fake_client = mocker.patch( + "snapcraft.commands.store.StoreClientCLI.verify_upload", + autospec=True, + return_value=None, + ) + return fake_client + + +@pytest.fixture +def snap_file(): + return str( + ( + pathlib.Path(unit.__file__) + / ".." + / ".." + / "legacy" + / "data" + / "test-snap.snap" + ).resolve() + ) + + +################## +# Upload Command # +################## + + +@pytest.mark.usefixtures("memory_keyring") +def test_default( + emitter, fake_store_notify_upload, fake_store_verify_upload, snap_file +): + cmd = commands.StoreUploadCommand(None) + + cmd.run( + argparse.Namespace( + snap_file=snap_file, channels=None, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ) - - def test_review_tools_not_available(self): - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains( - "Install the review-tools from the Snap Store for enhanced " - "checks before uploading this snap" - ), - ) - self.fake_review_tools_run.mock.assert_not_called() - - def test_upload_a_snap_review_tools_run_success(self): - self.fake_review_tools_is_available.mock.return_value = True - - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.fake_review_tools_run.mock.assert_called_once_with( - snap_filename=self.snap_file - ) - - def test_upload_a_snap_review_tools_run_fail(self): - self.fake_review_tools_is_available.mock.return_value = True - self.fake_review_tools_run.mock.side_effect = review_tools.errors.ReviewError( - { - "snap.v2_functional": {"error": {}, "warn": {}}, - "snap.v2_security": { - "error": { - "security-snap-v2:security_issue": { - "text": "(NEEDS REVIEW) security message." - } - }, - "warn": {}, - }, - "snap.v2_lint": {"error": {}, "warn": {}}, - } - ) - - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains( - "Review Tools did not fully pass for this snap.\n" - "Specific measures might need to be taken on the Snap Store before " - "this snap can be fully accepted.\n" - "Security Issues:\n" - "- (NEEDS REVIEW) security message" - ), ) - self.fake_review_tools_run.mock.assert_called_once_with( - snap_filename=self.snap_file - ) - - def test_upload_with_started_at(self): - snap_file = os.path.join( - os.path.dirname(tests.__file__), "data", "test-snap-with-started-at.snap" - ) - - # Upload - result = self.run_command(["upload", snap_file]) + ) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Revision 19 of 'basic' created.")) - self.fake_store_upload.mock.assert_called_once_with( + assert fake_store_verify_upload.mock_calls == [call(ANY, snap_name="basic")] + assert fake_store_notify_upload.mock_calls == [ + call( + ANY, snap_name="basic", - snap_filename=snap_file, - built_at="2019-05-07T19:25:53.939041Z", - channels=None, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ) - - def test_upload_without_login_must_ask(self): - self.fake_store_upload_precheck.mock.side_effect = [ - storeapi.http_clients.errors.InvalidCredentialsError("error"), - None, - ] - - result = self.run_command( - ["upload", self.snap_file], input="\n\n\n\nuser@example.com\nsecret\n" - ) - - self.assertThat( - result.output, Contains("You are required to login before continuing.") - ) - - def test_upload_nonexisting_snap_must_raise_exception(self): - result = self.run_command(["upload", "test-unexisting-snap"]) - - self.assertThat(result.exit_code, Equals(2)) - - def test_upload_invalid_snap_must_raise_exception(self): - snap_path = os.path.join( - os.path.dirname(tests.__file__), "data", "invalid.snap" - ) - - raised = self.assertRaises( - internal.errors.SnapDataExtractionError, - self.run_command, - ["upload", snap_path], - ) - - self.assertThat(str(raised), Contains("Cannot read data from snap")) - - def test_upload_unregistered_snap_must_ask(self): - class MockResponse: - status_code = 404 - - def json(self): - return dict( - error_list=[ - { - "code": "resource-not-found", - "message": "Snap not found for name=basic", - } - ] - ) - - self.fake_store_upload_precheck.mock.side_effect = [ - StoreUploadError("basic", MockResponse()), - None, - ] - - result = self.run_command(["upload", self.snap_file], input="y\n") - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat( - result.output, - Contains("You are required to register this snap before continuing. "), - ) - self.fake_store_register.mock.assert_called_once_with( - "basic", is_private=False, series="16", store_id=None - ) - - def test_upload_unregistered_snap_must_raise_exception_if_not_registering(self): - class MockResponse: - status_code = 404 - - def json(self): - return dict( - error_list=[ - { - "code": "resource-not-found", - "message": "Snap not found for name=basic", - } - ] - ) - - self.fake_store_upload_precheck.mock.side_effect = [ - StoreUploadError("basic", MockResponse()), - None, - ] - - raised = self.assertRaises( - storeapi.errors.StoreUploadError, - self.run_command, - ["upload", self.snap_file], - ) - - self.assertThat( - str(raised), - Contains("This snap is not registered. Register the snap and try again."), - ) - self.fake_store_register.mock.assert_not_called() - - def test_upload_with_updown_error(self): - # We really don't know of a reason why this would fail - # aside from a 5xx style error on the server. - class MockResponse: - text = "stub error" - reason = "stub reason" - - self.fake_store_upload.mock.side_effect = StoreUpDownError(MockResponse()) - - self.assertRaises( - storeapi.errors.StoreUpDownError, - self.run_command, - ["upload", self.snap_file], - ) - - def test_upload_raises_deprecation_warning(self): - fake_logger = fixtures.FakeLogger(level=logging.INFO) - self.useFixture(fake_logger) - - # Push - result = self.run_command(["push", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Revision 19 of 'basic' created.")) - self.assertThat( - fake_logger.output, - Contains( - "DEPRECATED: The 'push' set of commands have been replaced with 'upload'." - ), - ) - self.fake_store_upload.mock.assert_called_once_with( - snap_name="basic", - snap_filename=self.snap_file, + upload_id="2ecbfac1-3448-4e7d-85a4-7919b999f120", built_at=None, channels=None, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, + snap_file_size=4096, ) + ] + emitter.assert_message("Revision 10 created for 'basic'") - def test_upload_and_release_a_snap(self): - self.useFixture - # Upload - result = self.run_command(["upload", self.snap_file, "--release", "beta"]) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Revision 19 of 'basic' created")) - self.fake_store_upload.mock.assert_called_once_with( - snap_name="basic", - snap_filename=self.snap_file, - built_at=None, - channels=["beta"], - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ) +@pytest.mark.usefixtures("memory_keyring") +def test_default_channels( + emitter, fake_store_notify_upload, fake_store_verify_upload, snap_file +): + cmd = commands.StoreUploadCommand(None) - def test_upload_and_release_a_snap_to_N_channels(self): - # Upload - result = self.run_command( - ["upload", self.snap_file, "--release", "edge,beta,candidate"] + cmd.run( + argparse.Namespace( + snap_file=snap_file, + channels="stable,edge", ) + ) - self.assertThat(result.exit_code, Equals(0)) - self.assertThat(result.output, Contains("Revision 19 of 'basic' created")) - self.fake_store_upload.mock.assert_called_once_with( + assert fake_store_verify_upload.mock_calls == [call(ANY, snap_name="basic")] + assert fake_store_notify_upload.mock_calls == [ + call( + ANY, snap_name="basic", - snap_filename=self.snap_file, + upload_id="2ecbfac1-3448-4e7d-85a4-7919b999f120", built_at=None, - channels=["edge", "beta", "candidate"], - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ) - - def test_upload_displays_humanized_message(self): - result = self.run_command( - ["upload", self.snap_file, "--release", "edge,beta,candidate"] + channels=["stable", "edge"], + snap_file_size=4096, ) + ] + emitter.assert_message( + "Revision 10 created for 'basic' and released to 'edge' and 'stable'" + ) - self.assertThat( - result.output, - Contains( - "After uploading, the resulting snap revision will be released to " - "'beta', 'candidate', and 'edge' when it passes the Snap Store review." - ), - ) +def test_invalid_file(): + cmd = commands.StoreUploadCommand(None) -class UploadCommandDeltasTestCase(UploadCommandBaseTestCase): - def setUp(self): - super().setUp() - - self.latest_snap_revision = 8 - self.new_snap_revision = self.latest_snap_revision + 1 - - self.mock_tracker.track.return_value = { - "code": "ready_to_release", - "processed": True, - "can_release": True, - "url": "/fake/url", - "revision": self.new_snap_revision, - } - - def test_upload_revision_cached_with_experimental_deltas(self): - # Upload - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - snap_cache = os.path.join( - BaseDirectory.xdg_cache_home, - "snapcraft", - "projects", - "basic", - "snap_hashes", - "amd64", + with pytest.raises(craft_cli.errors.ArgumentParsingError) as raised: + cmd.run( + argparse.Namespace( + snap_file="invalid.snap", + channels=None, + ) ) - cached_snap = os.path.join( - snap_cache, file_utils.calculate_sha3_384(self.snap_file) - ) - - self.assertThat(cached_snap, FileExists()) - - def test_upload_revision_uses_available_delta(self): - # Upload - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - # Upload again - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - _, kwargs = self.fake_store_upload.mock.call_args - self.assertThat(kwargs.get("delta_format"), Equals("xdelta3")) - - def test_upload_with_delta_generation_failure_falls_back(self): - # Upload and ensure fallback is called - with mock.patch( - "snapcraft._store._upload_delta", - side_effect=StoreDeltaApplicationError("error"), - ): - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.fake_store_upload.mock.assert_called_once_with( - snap_name="basic", - snap_filename=self.snap_file, - built_at=None, - channels=None, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ) - - def test_upload_with_delta_upload_failure_falls_back(self): - # Upload - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - - result = { - "code": "processing_upload_delta_error", - "errors": [{"message": "Delta service failed to apply delta within 60s"}], - } - self.mock_tracker.raise_for_code.side_effect = [ - storeapi.errors.StoreReviewError(result=result), - None, - ] - - # Upload and ensure fallback is called - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - self.fake_store_upload.mock.assert_has_calls( - [ - mock.call( - snap_name="basic", - snap_filename=mock.ANY, - built_at=None, - channels=None, - delta_format="xdelta3", - delta_hash=mock.ANY, - source_hash=mock.ANY, - target_hash=mock.ANY, - ), - mock.call( - snap_name="basic", - snap_filename=self.snap_file, - built_at=None, - channels=None, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ), - ] - ) - - def test_upload_with_disabled_delta_falls_back(self): - # Upload - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - - class _FakeResponse: - status_code = 501 - reason = "disabled" - - def json(self): - return { - "error_list": [ - { - "code": "feature-disabled", - "message": "The delta upload support is currently disabled.", - } - ] - } - - self.fake_store_upload.mock.side_effect = [ - storeapi.http_clients.errors.StoreServerError(_FakeResponse()), - self.mock_tracker, - ] - - # Upload and ensure fallback is called - with mock.patch("snapcraft.storeapi._status_tracker.StatusTracker"): - result = self.run_command(["upload", self.snap_file]) - self.assertThat(result.exit_code, Equals(0)) - self.fake_store_upload.mock.assert_has_calls( - [ - mock.call( - snap_name="basic", - snap_filename=mock.ANY, - built_at=None, - channels=None, - delta_format="xdelta3", - delta_hash=mock.ANY, - source_hash=mock.ANY, - target_hash=mock.ANY, - ), - mock.call( - snap_name="basic", - snap_filename=self.snap_file, - built_at=None, - channels=None, - delta_format=None, - delta_hash=None, - source_hash=None, - target_hash=None, - ), - ] - ) - - -class UploadCommandDeltasWithPruneTestCase(UploadCommandBaseTestCase): - def run_test(self, cached_snaps): - snap_revision = 19 - - self.mock_tracker.track.return_value = { - "code": "ready_to_release", - "processed": True, - "can_release": True, - "url": "/fake/url", - "revision": snap_revision, - } - - deb_arch = "amd64" - - snap_cache = os.path.join( - BaseDirectory.xdg_cache_home, - "snapcraft", - "projects", - "basic", - "snap_hashes", - deb_arch, - ) - os.makedirs(snap_cache) - - for cached_snap in cached_snaps: - cached_snap = cached_snap.format(deb_arch) - open(os.path.join(snap_cache, cached_snap), "a").close() - - # Upload - result = self.run_command(["upload", self.snap_file]) - - self.assertThat(result.exit_code, Equals(0)) - - real_cached_snap = os.path.join( - snap_cache, file_utils.calculate_sha3_384(self.snap_file) - ) - - self.assertThat(os.path.join(snap_cache, real_cached_snap), FileExists()) - - for snap in cached_snaps: - snap = snap.format(deb_arch) - self.assertThat(os.path.join(snap_cache, snap), Not(FileExists())) - self.assertThat(len(os.listdir(snap_cache)), Equals(1)) - - def test_delete_other_cache_files_with_valid_name(self): - self.run_test( - ["a-cached-snap_0.3_{}_8.snap", "another-cached-snap_1.0_fakearch_6.snap"] - ) - - def test_delete_other_cache_file_with_invalid_name(self): - self.run_test( - [ - "a-cached-snap_0.3_{}.snap", - "cached-snap-without-revision_1.0_fakearch.snap", - "another-cached-snap-without-version_fakearch.snap", - ] - ) + assert str(raised.value) == "'invalid.snap' is not a valid file" diff --git a/tests/unit/commands/test_version.py b/tests/unit/commands/test_version.py index 78d077ed79..0f2c11043a 100644 --- a/tests/unit/commands/test_version.py +++ b/tests/unit/commands/test_version.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2017 Canonical Ltd +# Copyright 2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -13,21 +13,14 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from testtools.matchers import Equals -from . import CommandBaseTestCase +from argparse import Namespace +from snapcraft import __version__ +from snapcraft.commands.version import VersionCommand -class VersionCommandTestCase(CommandBaseTestCase): - def test_has_version(self): - result = self.run_command(["--version"]) - self.assertThat(result.exit_code, Equals(0)) - def test_has_version_without_hyphens(self): - result = self.run_command(["version"]) - self.assertThat(result.exit_code, Equals(0)) - - def test_method_return_same_value(self): - result1 = self.run_command(["version"]) - result2 = self.run_command(["--version"]) - self.assertEqual(result1.output, result2.output) +def test_version_command(emitter): + cmd = VersionCommand(None) + cmd.run(Namespace()) + emitter.assert_message(f"snapcraft {__version__}") diff --git a/tests/unit/commands/test_whoami.py b/tests/unit/commands/test_whoami.py deleted file mode 100644 index 285d276b97..0000000000 --- a/tests/unit/commands/test_whoami.py +++ /dev/null @@ -1,51 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017-2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from textwrap import dedent - -import pytest - -from snapcraft.storeapi.v2 import whoami -from snapcraft.storeapi import StoreClient - - -@pytest.fixture -def fake_dashboard_whoami(monkeypatch): - monkeypatch.setattr( - StoreClient, - "whoami", - lambda x: whoami.WhoAmI( - account=whoami.Account( - email="foo@bar.baz", - account_id="1234567890", - name="Foo from Baz", - username="foo", - ) - ), - ) - - -@pytest.mark.usefixtures("fake_dashboard_whoami") -def test_whoami(click_run): - result = click_run(["whoami"]) - - assert result.exit_code == 0 - assert result.output == dedent( - """\ - email: foo@bar.baz - developer-id: 1234567890 - """ - ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 9e73452b65..c42df88b81 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2020 Canonical Ltd +# Copyright 2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,95 +14,186 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import os -import pathlib -from typing import List -from unittest import mock +from typing import Any, Dict, Optional, Tuple import pytest -import xdg +from snapcraft import extensions -def pytest_generate_tests(metafunc): - idlist = [] - argvalues = [] - if metafunc.cls is None: - return - for scenario in metafunc.cls.scenarios: - idlist.append(scenario[0]) - items = scenario[1].items() - argnames = [x[0] for x in items] - argvalues.append([x[1] for x in items]) - metafunc.parametrize(argnames, argvalues, ids=idlist, scope="class") +@pytest.fixture +def fake_extension(): + """Basic extension.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {"grade": "fake-grade"} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-extension/fake-part": {"plugin": "nil"}} + + extensions.register("fake-extension", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension") + + +@pytest.fixture +def fake_extension_extra(): + """A variation of fake_extension with some conflicts and new code.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug", "fake-plug-extra"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension-extra/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-extension-extra/fake-part": {"plugin": "nil"}} + + extensions.register("fake-extension-extra", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension-extra") @pytest.fixture -def mock_subprocess_run(): - """A no-op subprocess.run mock.""" - patcher = mock.patch("subprocess.run") - yield patcher.start() - patcher.stop() +def fake_extension_invalid_parts(): + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {"grade": "fake-grade"} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-part": {"plugin": "nil"}, "fake-part-2": {"plugin": "nil"}} + + extensions.register("fake-extension-invalid-parts", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension-invalid-parts") @pytest.fixture -def tmp_work_path(tmp_path): - """Setup a temporary directory and chdir to it.""" - os.chdir(tmp_path) - return tmp_path +def fake_extension_experimental(): + """Basic extension.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return True + + def get_root_snippet(self) -> Dict[str, Any]: + return {} + + def get_app_snippet(self) -> Dict[str, Any]: + return {} + + def get_part_snippet(self) -> Dict[str, Any]: + return {} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {} + + extensions.register("fake-extension-experimental", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension-experimental") @pytest.fixture -def xdg_dirs(tmp_path, monkeypatch): - """Setup XDG directories in a temporary directory.""" - monkeypatch.setattr( - xdg.BaseDirectory, "xdg_config_home", (tmp_path / ".config").as_posix() - ) - monkeypatch.setattr( - xdg.BaseDirectory, "xdg_data_home", (tmp_path / ".local").as_posix() - ) - monkeypatch.setattr( - xdg.BaseDirectory, "xdg_cache_home", (tmp_path / ".cache").as_posix() - ) - monkeypatch.setattr( - xdg.BaseDirectory, - "xdg_config_dirs", - lambda: [(tmp_path / ".config").as_posix()], - ) - monkeypatch.setattr( - xdg.BaseDirectory, "xdg_data_dirs", lambda: [(tmp_path / ".config").as_posix()] - ) - - monkeypatch.setenv("XDG_CONFIG_HOME", (tmp_path / ".config").as_posix()) - monkeypatch.setenv("XDG_DATA_HOME", (tmp_path / ".local").as_posix()) - monkeypatch.setenv("XDG_CACHE_HOME", (tmp_path / ".cache").as_posix()) - - return tmp_path - - -@pytest.fixture() -def in_snap(monkeypatch): - """Simualte being run from within the context of the Snapcraft snap.""" - monkeypatch.setenv("SNAP", "/snap/snapcraft/current") - monkeypatch.setenv("SNAP_NAME", "snapcraft") - monkeypatch.setenv("SNAP_VERSION", "4.0") - - -@pytest.fixture() -def fake_exists(monkeypatch): - """Fakely return True when checking for preconfigured paths.""" - - class FileCheck: - def __init__(self) -> None: - self._original_exists = os.path.exists - self.paths: List[str] = list() - - def exists(self, path: str) -> bool: - if pathlib.Path(path) in self.paths: - return True - return self._original_exists(path) - - file_checker = FileCheck() - monkeypatch.setattr(os.path, "exists", file_checker.exists) - - return file_checker +def fake_extension_name_from_legacy(): + """A fake_extension variant with a name collision with legacy.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug", "fake-plug-extra"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension-extra/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-extension-extra/fake-part": {"plugin": "nil"}} + + extensions.register("ros2-foxy", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("ros2-foxy") diff --git a/tests/unit/extensions/__init__.py b/tests/unit/extensions/__init__.py new file mode 100644 index 0000000000..e4721fbbe3 --- /dev/null +++ b/tests/unit/extensions/__init__.py @@ -0,0 +1,15 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . diff --git a/tests/unit/extensions/conftest.py b/tests/unit/extensions/conftest.py new file mode 100644 index 0000000000..a335543170 --- /dev/null +++ b/tests/unit/extensions/conftest.py @@ -0,0 +1,164 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from typing import Any, Dict, Optional, Tuple + +import pytest + +from snapcraft import extensions + + +@pytest.fixture +def fake_extension(): + """Basic extension.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {"grade": "fake-grade"} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-extension/fake-part": {"plugin": "nil"}} + + extensions.register("fake-extension", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension") + + +@pytest.fixture +def fake_extension_extra(): + """A variation of fake_extension with some conflicts and new code.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug", "fake-plug-extra"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension-extra/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-extension-extra/fake-part": {"plugin": "nil"}} + + extensions.register("fake-extension-extra", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension-extra") + + +@pytest.fixture +def fake_extension_invalid_parts(): + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return False + + def get_root_snippet(self) -> Dict[str, Any]: + return {"grade": "fake-grade"} + + def get_app_snippet(self) -> Dict[str, Any]: + return {"plugs": ["fake-plug"]} + + def get_part_snippet(self) -> Dict[str, Any]: + return {"after": ["fake-extension/fake-part"]} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {"fake-part": {"plugin": "nil"}, "fake-part-2": {"plugin": "nil"}} + + extensions.register("fake-extension-invalid-parts", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension-invalid-parts") + + +@pytest.fixture +def fake_extension_experimental(): + """Basic extension.""" + + class ExtensionImpl(extensions.Extension): + """The test extension implementation.""" + + @staticmethod + def get_supported_bases() -> Tuple[str, ...]: + return ("core22",) + + @staticmethod + def get_supported_confinement() -> Tuple[str, ...]: + return ("strict",) + + @staticmethod + def is_experimental(base: Optional[str] = None) -> bool: + return True + + def get_root_snippet(self) -> Dict[str, Any]: + return {} + + def get_app_snippet(self) -> Dict[str, Any]: + return {} + + def get_part_snippet(self) -> Dict[str, Any]: + return {} + + def get_parts_snippet(self) -> Dict[str, Any]: + return {} + + extensions.register("fake-extension-experimental", ExtensionImpl) + yield ExtensionImpl + extensions.unregister("fake-extension-experimental") diff --git a/tests/unit/extensions/test_extensions.py b/tests/unit/extensions/test_extensions.py new file mode 100644 index 0000000000..e1ddb89ce4 --- /dev/null +++ b/tests/unit/extensions/test_extensions.py @@ -0,0 +1,225 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pytest + +from snapcraft import errors, extensions + + +@pytest.mark.usefixtures("fake_extension") +def test_apply_extension(): + yaml_data = { + "name": "fake-snap", + "summary": "fake summary", + "description": "fake description", + "base": "core22", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "plugs": ["my-fake-plug"], + "extensions": ["fake-extension"], + } + }, + "parts": {"fake-part": {"source": ".", "plugin": "dump"}}, + } + + assert extensions.apply_extensions( + yaml_data, arch="amd64", target_arch="amd64" + ) == { + "name": "fake-snap", + "summary": "fake summary", + "description": "fake description", + "base": "core22", + "grade": "fake-grade", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "plugs": ["fake-plug", "my-fake-plug"], + } + }, + "parts": { + "fake-part": { + "source": ".", + "plugin": "dump", + "after": ["fake-extension/fake-part"], + }, + "fake-extension/fake-part": {"plugin": "nil"}, + }, + } + + +@pytest.mark.usefixtures("fake_extension") +@pytest.mark.usefixtures("fake_extension_extra") +def test_apply_multiple_extensions(): + yaml_data = { + "name": "fake-snap", + "summary": "fake summary", + "description": "fake description", + "base": "core22", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "plugs": ["my-fake-plug"], + "extensions": ["fake-extension", "fake-extension-extra"], + } + }, + "parts": {"fake-part": {"source": ".", "plugin": "dump"}}, + } + + assert extensions.apply_extensions( + yaml_data, arch="amd64", target_arch="amd64" + ) == { + "name": "fake-snap", + "summary": "fake summary", + "description": "fake description", + "base": "core22", + "grade": "fake-grade", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "plugs": ["fake-plug", "fake-plug-extra", "my-fake-plug"], + } + }, + "parts": { + "fake-part": { + "source": ".", + "plugin": "dump", + "after": ["fake-extension-extra/fake-part", "fake-extension/fake-part"], + }, + "fake-extension/fake-part": { + "plugin": "nil", + "after": ["fake-extension-extra/fake-part"], + }, + "fake-extension-extra/fake-part": {"plugin": "nil"}, + }, + } + + +@pytest.mark.usefixtures("fake_extension") +def test_apply_extension_wrong_base(): + yaml_data = { + "base": "core20", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "extensions": ["fake-extension"], + } + }, + } + + with pytest.raises(errors.ExtensionError) as raised: + extensions.apply_extensions(yaml_data, arch="amd64", target_arch="amd64") + + assert ( + str(raised.value) + == "Extension 'fake-extension' does not support base: 'core20'" + ) + + +@pytest.mark.usefixtures("fake_extension") +def test_apply_extension_wrong_confinement(): + yaml_data = { + "base": "core22", + "confinement": "classic", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "extensions": ["fake-extension"], + } + }, + } + + with pytest.raises(errors.ExtensionError) as raised: + extensions.apply_extensions(yaml_data, arch="amd64", target_arch="amd64") + + assert ( + str(raised.value) + == "Extension 'fake-extension' does not support confinement 'classic'" + ) + + +@pytest.mark.usefixtures("fake_extension_invalid_parts") +def test_apply_extension_invalid_parts(): + # This is a Snapcraft developer error. + yaml_data = { + "base": "core22", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "extensions": ["fake-extension-invalid-parts"], + } + }, + } + + with pytest.raises(ValueError) as raised: + extensions.apply_extensions(yaml_data, arch="amd64", target_arch="amd64") + + assert str(raised.value) == ( + "Extension has invalid part names: ['fake-part', 'fake-part-2']. " + "Format is /" + ) + + +@pytest.mark.usefixtures("fake_extension_experimental") +def test_apply_extension_experimental(): + yaml_data = { + "base": "core22", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "extensions": ["fake-extension-experimental"], + } + }, + } + + with pytest.raises(errors.ExtensionError) as raised: + extensions.apply_extensions(yaml_data, arch="amd64", target_arch="amd64") + + assert ( + str(raised.value) == "Extension is experimental: 'fake-extension-experimental'" + ) + assert raised.value.docs_url == "https://snapcraft.io/docs/supported-extensions" + + +@pytest.mark.usefixtures("fake_extension_experimental") +def test_apply_extension_experimental_with_environment(emitter, monkeypatch): + monkeypatch.setenv("SNAPCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "1") + + yaml_data = { + "base": "core22", + "apps": { + "fake-command": { + "command": "bin/fake-command", + "extensions": ["fake-extension-experimental"], + } + }, + "parts": { + "fake-part": { + "source": ".", + "plugin": "dump", + "after": ["fake-extension-extra/fake-part", "fake-extension/fake-part"], + }, + }, + } + + # Should not raise. + extensions.apply_extensions(yaml_data, arch="amd64", target_arch="amd64") + + emitter.assert_message( + "*EXPERIMENTAL* extension 'fake-extension-experimental' enabled", + intermediate=True, + ) diff --git a/tests/unit/extensions/test_registry.py b/tests/unit/extensions/test_registry.py new file mode 100644 index 0000000000..256945b562 --- /dev/null +++ b/tests/unit/extensions/test_registry.py @@ -0,0 +1,42 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest + +from snapcraft import errors, extensions + + +@pytest.mark.usefixtures("fake_extension") +@pytest.mark.usefixtures("fake_extension_extra") +@pytest.mark.usefixtures("fake_extension_experimental") +def test_get_extension_names(): + assert extensions.get_extension_names() == [ + "fake-extension-experimental", + "fake-extension-extra", + "fake-extension", + ] + + +def test_get_extension_class(fake_extension): + assert extensions.get_extension_class("fake-extension") == fake_extension + + +def test_get_extesion_class_not_found(): + # This is a developer error. + with pytest.raises(errors.ExtensionError) as raised: + extensions.get_extension_class("fake-extension-not-found") + + assert str(raised.value) == "Extension 'fake-extension-not-found' does not exist" diff --git a/tests/unit/extensions/test_utils.py b/tests/unit/extensions/test_utils.py new file mode 100644 index 0000000000..d279759c2c --- /dev/null +++ b/tests/unit/extensions/test_utils.py @@ -0,0 +1,82 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pytest + +from snapcraft.extensions._utils import _apply_extension_property + + +@pytest.mark.parametrize( + "existing_property,extension_property,expected_value", + [ + # prepend + ( + ["item3", "item4", "item5"], + ["item1", "item2"], + ["item1", "item2", "item3", "item4", "item5"], + ), + # empty extension + (["item3", "item4", "item5"], [], ["item3", "item4", "item5"]), + # empty property + ([], ["item1", "item2"], ["item1", "item2"]), + # duplicate items keeps first found + ( + ["item3", "item4", "item1"], + ["item1", "item2"], + ["item1", "item2", "item3", "item4"], + ), + # non scalar + ( + [{"k2": "v2"}], + [{"k1": "v1"}], + [{"k1": "v1"}, {"k2": "v2"}], + ), + ], +) +def test_apply_property_list(existing_property, extension_property, expected_value): + assert ( + _apply_extension_property(existing_property, extension_property) + == expected_value + ) + + +@pytest.mark.parametrize( + "existing_property,extension_property,expected_value", + [ + # add + ( + {"k1": "v1", "k2": "v2", "k3": "v3"}, + {"k4": "v4", "k5": "v5"}, + {"k1": "v1", "k2": "v2", "k3": "v3", "k4": "v4", "k5": "v5"}, + ), + # conflicts keeps existing property + ( + {"k1": "v1", "k2": "v2", "k3": "v3"}, + {"k3": "nv3", "k4": "v4"}, + {"k1": "v1", "k2": "v2", "k3": "v3", "k4": "v4"}, + ), + # empty property + ({}, {"k4": "v4", "k5": "v5"}, {"k4": "v4", "k5": "v5"}), + ], +) +def test_apply_property_dictionary( + existing_property, extension_property, expected_value +): + assert ( + _apply_extension_property(existing_property, extension_property) + == expected_value + ) diff --git a/tests/unit/meta/test_appstream.py b/tests/unit/meta/test_appstream.py new file mode 100644 index 0000000000..2acc27df9d --- /dev/null +++ b/tests/unit/meta/test_appstream.py @@ -0,0 +1,805 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2017-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import textwrap +from pathlib import Path +from typing import Optional + +import pytest + +from snapcraft import errors +from snapcraft.meta import ExtractedMetadata, appstream + + +def _create_desktop_file(desktop_file_path, icon: Optional[str] = None) -> None: + dir_name = os.path.dirname(desktop_file_path) + if not os.path.exists(dir_name): + os.makedirs(dir_name) + with open(desktop_file_path, "w") as f: + print("[Desktop Entry]", file=f) + if icon: + print(f"Icon={icon}", file=f) + + +@pytest.mark.usefixtures("new_dir") +class TestAppstreamData: + """Extract metadata and check each extracted field.""" + + @pytest.mark.parametrize("file_extension", ["metainfo.xml", "appdata.xml"]) + @pytest.mark.parametrize( + "key,attributes,param_name,value,expect", + [ + ("summary", {}, "summary", "test-summary", "test-summary"), + ("description", {}, "description", "test-description", "test-description"), + ("icon", {"type": "local"}, "icon", "/test/path", None), + ("icon", {"type": "local"}, "icon", "/icon.png", "/icon.png"), + ("id", {}, "common_id", "test-id", "test-id"), + ("name", {}, "title", "test-title", "test-title"), + ], + ) + def test_entries(self, file_extension, key, attributes, param_name, value, expect): + file_name = f"foo.{file_extension}" + attrs = " ".join(f'{attr}="{attributes[attr]}"' for attr in attributes) + Path(file_name).write_text( + textwrap.dedent( + f"""\ + + + <{key} {attrs}>{value} + """ + ) + ) + + Path("icon.png").touch() + kwargs = {param_name: expect} + expected = ExtractedMetadata(**kwargs) + + assert appstream.extract(file_name, workdir=".") == expected + + +# See LP #1814898 for a description of possible fallbacks +@pytest.mark.usefixtures("new_dir") +class TestAppstreamIcons: + """Check extraction of icon-related metadata.""" + + def _create_appstream_file( + self, icon: Optional[str] = None, icon_type: str = "local" + ): + with open("foo.appdata.xml", "w") as f: + if icon: + f.write( + textwrap.dedent( + f"""\ + + + my.app.desktop + {icon} + """ + ) + ) + else: + f.write( + textwrap.dedent( + """\ + + + my.app.desktop + """ + ) + ) + + def _create_index_theme(self, theme: str): + # TODO: populate index.theme + dir_name = os.path.join("usr", "share", "icons", theme) + if not os.path.exists(dir_name): + os.makedirs(dir_name) + Path(dir_name, "index.theme").touch() + + def _create_icon_file(self, theme: str, size: str, filename: str) -> None: + dir_name = os.path.join("usr", "share", "icons", theme, size, "apps") + if not os.path.exists(dir_name): + os.makedirs(dir_name) + Path(dir_name, filename).touch() + + def _expect_icon(self, icon): + expected = ExtractedMetadata(icon=icon) + actual = appstream.extract("foo.appdata.xml", workdir=".") + assert actual is not None + assert actual.icon == expected.icon + + def test_appstream_NxN_size_not_int_is_skipped(self): + self._create_appstream_file(icon="icon", icon_type="stock") + dir_name = os.path.join("usr", "share", "icons", "hicolor", "NxN") + os.makedirs(dir_name) + self._expect_icon(None) + + def test_appstream_index_theme_is_not_confused_for_size(self): + self._create_appstream_file(icon="icon", icon_type="stock") + self._create_index_theme("hicolor") + self._expect_icon(None) + + def test_appstream_stock_icon_exists_png(self): + self._create_appstream_file(icon="icon", icon_type="stock") + self._create_icon_file("hicolor", "48x48", "icon.png") + self._create_icon_file("hicolor", "64x64", "icon.png") + self._expect_icon("usr/share/icons/hicolor/64x64/apps/icon.png") + + def test_appstream_stock_icon_not_exist(self): + self._create_appstream_file(icon="missing", icon_type="stock") + self._expect_icon(None) + + def test_appstream_no_icon_no_fallback(self): + self._create_appstream_file() + self._expect_icon(None) + + def test_appstream_local_icon_exists(self): + self._create_appstream_file(icon="/icon.png") + Path("icon.png").touch() + self._expect_icon("/icon.png") + + def test_appstream_local_icon_not_exist_no_fallback(self): + self._create_appstream_file(icon="/missing.png") + self._expect_icon(None) + + def test_appstream_local_icon_not_absolute_no_fallback(self): + self._create_appstream_file(icon="foo") + self._expect_icon(None) + + def test_appstream_no_icon_desktop_fallback_no_icon(self): + self._create_appstream_file() + _create_desktop_file("usr/share/applications/my.app.desktop") + self._expect_icon(None) + + def test_appstream_no_icon_desktop_fallback_icon_not_exist(self): + self._create_appstream_file() + _create_desktop_file( + "usr/share/applications/my.app.desktop", icon="/missing.png" + ) + self._expect_icon("/missing.png") + + def test_appstream_no_icon_desktop_fallback_icon_exists(self): + self._create_appstream_file() + _create_desktop_file("usr/share/applications/my.app.desktop", icon="/icon.png") + Path("icon.png").touch() + self._expect_icon("/icon.png") + + def test_appstream_no_icon_theme_fallback_png(self): + self._create_appstream_file() + _create_desktop_file("usr/share/applications/my.app.desktop", icon="icon") + self._create_icon_file("hicolor", "scalable", "icon.svg") + self._create_icon_file("hicolor", "48x48", "icon.png") + self._create_icon_file("hicolor", "64x64", "icon.png") + self._expect_icon("usr/share/icons/hicolor/64x64/apps/icon.png") + + def test_appstream_no_icon_theme_fallback_xpm(self): + self._create_appstream_file() + _create_desktop_file("usr/share/applications/my.app.desktop", icon="icon") + self._create_icon_file("hicolor", "scalable", "icon.svg") + self._create_icon_file("hicolor", "48x48", "icon.png") + self._create_icon_file("hicolor", "64x64", "icon.xpm") + self._expect_icon("usr/share/icons/hicolor/64x64/apps/icon.xpm") + + def test_appstream_no_icon_theme_fallback_svg(self): + self._create_appstream_file() + _create_desktop_file("usr/share/applications/my.app.desktop", icon="icon") + self._create_icon_file("hicolor", "scalable", "icon.svg") + self._expect_icon("usr/share/icons/hicolor/scalable/apps/icon.svg") + + def test_appstream_no_icon_theme_fallback_svgz(self): + self._create_appstream_file() + _create_desktop_file("usr/share/applications/my.app.desktop", icon="icon") + self._create_icon_file("hicolor", "scalable", "icon.svgz") + self._expect_icon("usr/share/icons/hicolor/scalable/apps/icon.svgz") + + +@pytest.mark.usefixtures("new_dir") +class TestAppstreamContent: + """Check variations of the Appstream file content.""" + + def test_appstream_with_ul(self): + file_name = "snapcraft_legacy.appdata.xml" + content = textwrap.dedent( + """\ + + + io.snapcraft.snapcraft + CC0-1.0 + GPL-3.0 + snapcraft + snapcraft + Create snaps + Crea snaps + +

      Command Line Utility to create snaps.

      +

      Aplicativo de línea de comandos para crear snaps.

      +

      Features:

      +

      Funciones:

      +
        +
      • Build snaps.
      • +
      • Construye snaps.
      • +
      • Publish snaps to the store.
      • +
      • Publica snaps en la tienda.
      • +
      +
      + + snapcraft + +
      + """ + ) + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.summary == "Create snaps" + assert metadata.description == textwrap.dedent( + """\ + Command Line Utility to create snaps. + + Features: + + - Build snaps. + - Publish snaps to the store.""" + ) + + def test_appstream_with_ol(self): + file_name = "snapcraft_legacy.appdata.xml" + content = textwrap.dedent( + """\ + + + io.snapcraft.snapcraft + CC0-1.0 + GPL-3.0 + snapcraft + snapcraft + Create snaps + Crea snaps + +

      Command Line Utility to create snaps.

      +

      Aplicativo de línea de comandos para crear snaps.

      +

      Features:

      +

      Funciones:

      +
        +
      1. Build snaps.
      2. +
      3. Construye snaps.
      4. +
      5. Publish snaps to the store.
      6. +
      7. Publica snaps en la tienda.
      8. +
      +
      + + snapcraft + +
      + """ + ) + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.summary == "Create snaps" + assert metadata.description == textwrap.dedent( + """\ + Command Line Utility to create snaps. + + Features: + + 1. Build snaps. + 2. Publish snaps to the store.""" + ) + + def test_appstream_with_ul_in_p(self): + file_name = "snapcraft_legacy.appdata.xml" + # pylint: disable=line-too-long + content = textwrap.dedent( + """\ + + + com.github.maoschanz.drawing + CC0-1.0 + GPL-3.0-or-later + + Drawing + Çizim + Drawing + A drawing application for the GNOME desktop + Uma aplicacao de desenho para o ambiente GNOME + Een tekenprogramma voor de GNOME-werkomgeving + +

      "Drawing" is a basic image editor, supporting PNG, JPEG and BMP file types.

      +

      "Drawing" e um simples editor de imagens, que suporta arquivos PNG,JPEG e BMP

      +

      "Tekenen" is een eenvoudige afbeeldingsbewerker, met ondersteuning voor PNG, JPEG en BMP.

      +

      "Dessin" est un éditeur d'images basique, qui supporte les fichiers de type PNG, JPEG ou BMP.

      +

      It allows you to draw or edit pictures with tools such as: +

        +
      • Pencil (with various options)
      • +
      • Lápis (Com varias opções)
      • +
      • Potlood (verschillende soorten)
      • +
      • Crayon (avec diverses options)
      • +
      • Selection (cut/copy/paste/drag/…)
      • +
      • Seçim (kes/kopyala/yapıştır/sürükle /…)
      • +
      • Выделение (вырезать/копировать/вставить/перетащить/…)
      • +
      • Seleção (cortar/copiar/colar/arrastar/…)
      • +
      • Selectie (knippen/kopiëren/plakken/verslepen/...)
      • +
      • Selezione (taglia/copia/incolla/trascina/…)
      • +
      • בחירה (חתיכה/העתקה/הדבקה/גרירה/...)
      • +
      • Sélection (copier/coller/déplacer/…)
      • +
      • Selección (cortar/copiar/pegar/arrastrar/…)
      • +
      • Auswahl (Ausschneiden/Kopieren/Einfügen/Ziehen/...)
      • +
      • Line, Arc (with various options)
      • +
      • Linha, Arco (com varias opcoes)
      • +
      • Lijn, Boog (verschillende soorten)
      • +
      • Trait, Arc (avec diverses options)
      • +
      • Shapes (rectangle, circle, polygon, …)
      • +
      • Formas (retângulo, circulo, polígono, …)
      • +
      • Vormen (vierkant, cirkel, veelhoek, ...)
      • +
      • Formes (rectangle, cercle, polygone, …)
      • +
      • Text insertion
      • +
      • Inserção de texto
      • +
      • Tekst invoeren
      • +
      • Insertion de texte
      • +
      • Resizing, cropping, rotating
      • +
      • Redimencionar, cortar, rotacionar
      • +
      • Afmetingen wijzigen, bijsnijden, draaien
      • +
      • Redimensionnement, rognage, rotation
      • +
      +

      +
      +
      + """ + ) + # pylint: enable=line-too-long + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.summary == "A drawing application for the GNOME desktop" + assert metadata.description == textwrap.dedent( + """\ + "Drawing" is a basic image editor, supporting PNG, JPEG and BMP file types. + + It allows you to draw or edit pictures with tools such as: + + - Pencil (with various options) + - Selection (cut/copy/paste/drag/…) + - Line, Arc (with various options) + - Shapes (rectangle, circle, polygon, …) + - Text insertion + - Resizing, cropping, rotating""" + ) + + def test_appstream_multilang_title(self): + file_name = "foliate.appdata.xml" + content = textwrap.dedent( + """\ + + + Foliate + Foliate_id + Foliate_pt + Foliate_ru + Foliate_nl + Foliate_fr + Foliate_cs + + """ + ) + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.title == "Foliate" + + def test_appstream_release(self): + file_name = "foliate.appdata.xml" + # pylint: disable=line-too-long + content = textwrap.dedent( + """\ + + + + + +
        +
      • Fixed Flatpak version not being able to open .mobi, .azw, and .azw3 files
      • +
      • Improved Wiktionary lookup, now with links and example sentences
      • +
      • Improved popover footnote extraction and formatting
      • +
      • Added option to export annotations to BibTeX
      • +
      +
      +
      + + +
        +
      • Fixed table of contents navigation not working with some books
      • +
      • Fixed not being able to zoom images with Kindle books
      • +
      • Fixed not being able to open books with .epub3 filename extension
      • +
      • Fixed temporary directory not being cleaned after closing
      • +
      +
      +
      + + +
        +
      • Fixed F9 shortcut not working
      • +
      • Updated translations
      • +
      +
      +
      +
      +
      + """ + ) + # pylint: enable=line-too-long + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.version == "1.5.3" + + def test_appstream_em(self): + file_name = "foliate.appdata.xml" + content = textwrap.dedent( + """\ + + + com.github.maoschanz.drawing + CC0-1.0 + GPL-3.0-or-later + + Drawing + +

      Command Line Utility to create snaps quickly.

      +

      Aplicativo de línea de comandos para crear snaps.

      +

      Ordered Features:

      +

      Funciones:

      +
        +
      1. Build snaps.
      2. +
      3. Construye snaps.
      4. +
      5. Publish snaps to the store.
      6. +
      7. Publica snaps en la tienda.
      8. +
      +

      Unordered Features:

      +
        +
      • Build snaps.
      • +
      • Construye snaps.
      • +
      • Publish snaps to the store.
      • +
      • Publica snaps en la tienda.
      • +
      +
      +
      + """ + ) + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.description == textwrap.dedent( + """\ + Command Line Utility to _create snaps_ quickly. + + Ordered Features: + + 1. _Build snaps_. + 2. Publish snaps to the store. + + Unordered Features: + + - _Build snaps_. + - Publish snaps to the store.""" + ) + + def test_appstream_code_tags_not_swallowed(self): + file_name = "foliate.appdata.xml" + content = textwrap.dedent( + """\ + + + com.github.maoschanz.drawing + CC0-1.0 + GPL-3.0-or-later + + Drawing + +

      Command Line Utility to create snaps quickly.

      +

      Aplicativo de línea de comandos para crear snaps.

      +

      Ordered Features:

      +

      Funciones:

      +
        +
      1. Build snaps.
      2. +
      3. Construye snaps.
      4. +
      5. Publish snaps to the store.
      6. +
      7. Publica snaps en la tienda.
      8. +
      +

      Unordered Features:

      +
        +
      • Build snaps.
      • +
      • Construye snaps.
      • +
      • Publish snaps to the store.
      • +
      • Publica snaps en la tienda.
      • +
      +
      +
      + """ + ) + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.description == textwrap.dedent( + """\ + Command Line Utility to create snaps quickly. + + Ordered Features: + + 1. Build snaps. + 2. Publish snaps to the store. + + Unordered Features: + + - Build snaps. + - Publish snaps to the store.""" + ) + + def test_appstream_with_comments(self): + file_name = "foo.appdata.xml" + content = textwrap.dedent( + """\ + + + com.github.maoschanz.drawing + CC0-1.0 + GPL-3.0-or-later + + + Drawing + + Draw stuff + + +

      Command Line Utility to create snaps quickly.

      +

      Aplicativo de línea de comandos para crear snaps.

      +

      Ordered Features:

      +

      Funciones:

      +
        +
      1. Build snaps.
      2. +
      3. Construye snaps.
      4. +
      5. Publish snaps to the store.
      6. +
      7. Publica snaps en la tienda.
      8. +
      +

      Unordered Features:

      +
        +
      • Build snaps.
      • +
      • Construye snaps.
      • +
      • Publish snaps to the store.
      • +
      • Publica snaps en la tienda.
      • +
      +
      +
      + """ + ) + + Path(file_name).write_text(content) + + metadata = appstream.extract(file_name, workdir=".") + + assert metadata is not None + assert metadata.description == textwrap.dedent( + """\ + Command Line Utility to create snaps quickly. + + Ordered Features: + + 1. Build snaps. + 2. Publish snaps to the store. + + Unordered Features: + + - Build snaps. + - Publish snaps to the store.""" + ) + + def test_appstream_parse_error(self): + file_name = "snapcraft_legacy.appdata.xml" + content = textwrap.dedent( + """\ + + + io.snapcraft.snapcraft + CC0-1.0 + GPL-3.0 + snapcraft + Create snaps + +

      Command Line Utility to create snaps.

      +
      + + snapcraft +
      + """ + ) + + Path(file_name).write_text(content) + + with pytest.raises(errors.MetadataExtractionError) as raised: + appstream.extract(file_name, workdir=".") + + assert str(raised.value) == ( + "Error extracting metadata from './snapcraft_legacy.appdata.xml': " + "Opening and ending tag mismatch: provides line 11 and component, " + "line 13, column 13 (snapcraft_legacy.appdata.xml, line 13)" + ) + + def test_appstream_parse_os_error(self): + file_name = "snapcraft_legacy.appdata.xml" + assert not Path(file_name).is_file() + + error = "Error reading file './snapcraft_legacy.appdata.xml': failed to load" + with pytest.raises(errors.SnapcraftError, match=error): + appstream.extract(file_name, workdir=".") + + +@pytest.mark.usefixtures("new_dir") +class TestAppstreamUnhandledFile: + """Unhandled files should return None.""" + + def test_unhandled_file_test_case(self): + assert appstream.extract("unhandled-file", workdir=".") is None + + +@pytest.mark.usefixtures("new_dir") +class TestAppstreamLaunchable: + """Desktop file path must be extracted correctly.""" + + @pytest.mark.parametrize( + "desktop_file_path", + [ + "usr/share/applications/com.example.test/app.desktop", + "usr/local/share/applications/com.example.test/app.desktop", + ], + ) + def test(self, desktop_file_path): + Path("foo.metainfo.xml").write_text( + textwrap.dedent( + """\ + + + + com.example.test-app.desktop + + """ + ) + ) + + _create_desktop_file(desktop_file_path) + + extracted = appstream.extract("foo.metainfo.xml", workdir=".") + + assert extracted is not None + assert extracted.desktop_file_paths == [desktop_file_path] + + +@pytest.mark.usefixtures("new_dir") +class TestAppstreamLegacyDesktop: + """Legacy desktop file path must be extracted correctly.""" + + @pytest.mark.parametrize( + "desktop_file_path", + [ + "usr/share/applications/com.example.test/app.desktop", + "usr/local/share/applications/com.example.test/app.desktop", + ], + ) + def test_launchable(self, desktop_file_path): + Path("foo.metainfo.xml").write_text( + textwrap.dedent( + """\ + + + com.example.test-app.desktop + """ + ) + ) + + _create_desktop_file(desktop_file_path) + + extracted = appstream.extract("foo.metainfo.xml", workdir=".") + + assert extracted is not None + assert extracted.desktop_file_paths == [desktop_file_path] + + @pytest.mark.parametrize( + "desktop_file_path", + [ + "usr/share/applications/com.example.test/app.desktop", + "usr/local/share/applications/com.example.test/app.desktop", + ], + ) + def test_appstream_no_desktop_suffix(self, desktop_file_path): + Path("foo.metainfo.xml").write_text( + textwrap.dedent( + """\ + + + com.example.test-app + """ + ) + ) + + _create_desktop_file(desktop_file_path) + + extracted = appstream.extract("foo.metainfo.xml", workdir=".") + + assert extracted is not None + assert extracted.desktop_file_paths == [desktop_file_path] + + +@pytest.mark.usefixtures("new_dir") +class TestAppstreamMultipleLaunchable: + """Multiple desktop file paths must be extracted correctly.""" + + def test_appstream_with_multiple_launchables(self): + Path("foo.metainfo.xml").write_text( + textwrap.dedent( + """\ + + + + com.example.test-app1.desktop + + + dummy + + + com.example.test-app2.desktop + + + unexisting + + """ + ) + ) + + _create_desktop_file( + "usr/local/share/applications/com.example.test/app1.desktop" + ) + _create_desktop_file( + "usr/local/share/applications/com.example.test/app2.desktop" + ) + + expected = [ + "usr/local/share/applications/com.example.test/app1.desktop", + "usr/local/share/applications/com.example.test/app2.desktop", + ] + extracted = appstream.extract("foo.metainfo.xml", workdir=".") + + assert extracted is not None + assert extracted.desktop_file_paths == expected diff --git a/tests/unit/meta/test_metadata.py b/tests/unit/meta/test_metadata.py new file mode 100644 index 0000000000..debce4c821 --- /dev/null +++ b/tests/unit/meta/test_metadata.py @@ -0,0 +1,28 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from unittest.mock import call + +from snapcraft import meta + + +def test_extract_metadata(mocker): + mock_appstream_extract = mocker.patch("snapcraft.meta.appstream.extract") + meta.extract_metadata("some/file", workdir="workdir") + + assert mock_appstream_extract.mock_calls == [ + call("some/file", workdir="workdir"), + ] diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py new file mode 100644 index 0000000000..ce2411dcc8 --- /dev/null +++ b/tests/unit/meta/test_snap_yaml.py @@ -0,0 +1,294 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import textwrap +from pathlib import Path + +import pytest +import yaml + +from snapcraft.meta import snap_yaml +from snapcraft.projects import Project + + +@pytest.fixture +def simple_project(): + snapcraft_yaml = textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + base: core22 + summary: Single-line elevator pitch for your amazing snap + description: | + This is my-snap's description. You have a paragraph or two to tell the + most important story about your snap. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the snap + store. + + confinement: strict + + parts: + part1: + plugin: nil + + apps: + app1: + command: bin/mytest + """ + ) + data = yaml.safe_load(snapcraft_yaml) + yield Project.unmarshal(data) + + +def test_simple_snap_yaml(simple_project, new_dir): + snap_yaml.write( + simple_project, + prime_dir=Path(new_dir), + arch="arch", + ) + yaml_file = Path("meta/snap.yaml") + assert yaml_file.is_file() + + content = yaml_file.read_text() + assert content == textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + summary: Single-line elevator pitch for your amazing snap + description: | + This is my-snap's description. You have a paragraph or two to tell the + most important story about your snap. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the snap + store. + architectures: + - arch + base: core22 + assumes: + - command-chain + apps: + app1: + command: bin/mytest + command-chain: + - snap/command-chain/snapcraft-runner + confinement: strict + grade: stable + """ + ) + + +@pytest.fixture +def complex_project(): + snapcraft_yaml = textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + base: core22 + type: app + summary: Single-line elevator pitch for your amazing snap + description: | + This is my-snap's description. You have a paragraph or two to tell the + most important story about your snap. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the snap + store. + + grade: devel + confinement: strict + + environment: + GLOBAL_VARIABLE: test-global-variable + + parts: + part1: + plugin: nil + + apps: + app1: + command: bin/mytest + autostart: test-app.desktop + common-id: test-common-id + bus-name: test-bus-name + completer: test-completer + stop-command: test-stop-command + post-stop-command: test-post-stop-command + start-timeout: 1s + stop-timeout: 2s + watchdog-timeout: 3s + reload-command: test-reload-command + restart-delay: 4s + timer: test-timer + daemon: simple + after: [test-after-1, test-after-2] + before: [test-before-1, test-before-2] + refresh-mode: endure + stop-mode: sigterm + restart-condition: on-success + install-mode: enable + aliases: [test-alias-1, test-alias-2] + environment: + APP_VARIABLE: test-app-variable + adapter: none + command-chain: + - snap/command-chain/snapcraft-runner + sockets: + test-socket-1: + listen-stream: /tmp/test-socket.sock + socket-mode: 0 + test-socket-2: + listen-stream: 100 + socket-mode: 1 + + plugs: + empty-plug: + string-plug: home + dict-plug: + string-parameter: foo + bool-parameter: True + content-interface: + interface: content + target: test-target + content: test-content + default-provider: test-provider + + hooks: + configure: + command-chain: ["test"] + environment: + test-variable-1: "test" + test-variable-2: "test" + plugs: + - home + - network + install: + environment: + environment-var-1: "test" + + layout: + /usr/share/libdrm: + bind: $SNAP/gnome-platform/usr/share/libdrm + /usr/lib/x86_64-linux-gnu/webkit2gtk-4.0: + bind: $SNAP/gnome-platform/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 + /usr/share/xml/iso-codes: + bind: $SNAP/gnome-platform/usr/share/xml/iso-codes + """ + ) + data = yaml.safe_load(snapcraft_yaml) + yield Project.unmarshal(data) + + +def test_complex_snap_yaml(complex_project, new_dir): + snap_yaml.write( + complex_project, + prime_dir=Path(new_dir), + arch="arch", + ) + yaml_file = Path("meta/snap.yaml") + assert yaml_file.is_file() + + content = yaml_file.read_text() + assert content == textwrap.dedent( + """\ + name: mytest + version: 1.29.3 + summary: Single-line elevator pitch for your amazing snap + description: | + This is my-snap's description. You have a paragraph or two to tell the + most important story about your snap. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the snap + store. + type: app + architectures: + - arch + base: core22 + assumes: + - command-chain + apps: + app1: + command: bin/mytest + autostart: test-app.desktop + common-id: test-common-id + bus-name: test-bus-name + completer: test-completer + stop-command: test-stop-command + post-stop-command: test-post-stop-command + start-timeout: 1s + stop-timeout: 2s + watchdog-timeout: 3s + reload-command: test-reload-command + restart-delay: 4s + timer: test-timer + daemon: simple + after: + - test-after-1 + - test-after-2 + before: + - test-before-1 + - test-before-2 + refresh-mode: endure + stop-mode: sigterm + restart-condition: on-success + install-mode: enable + aliases: + - test-alias-1 + - test-alias-2 + environment: + APP_VARIABLE: test-app-variable + adapter: none + command-chain: + - snap/command-chain/snapcraft-runner + sockets: + test-socket-1: + listen-stream: /tmp/test-socket.sock + socket-mode: 0 + test-socket-2: + listen-stream: 100 + socket-mode: 1 + confinement: strict + grade: devel + environment: + GLOBAL_VARIABLE: test-global-variable + plugs: + empty-plug: null + string-plug: home + dict-plug: + string-parameter: foo + bool-parameter: true + content-interface: + content: test-content + interface: content + target: test-target + default-provider: test-provider + hooks: + configure: + command-chain: + - test + environment: + test-variable-1: test + test-variable-2: test + plugs: + - home + - network + install: + environment: + environment-var-1: test + layout: + /usr/share/libdrm: + bind: $SNAP/gnome-platform/usr/share/libdrm + /usr/lib/x86_64-linux-gnu/webkit2gtk-4.0: + bind: $SNAP/gnome-platform/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0 + /usr/share/xml/iso-codes: + bind: $SNAP/gnome-platform/usr/share/xml/iso-codes + """ + ) diff --git a/tests/unit/parts/__init__.py b/tests/unit/parts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/parts/plugins/__init__.py b/tests/unit/parts/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/parts/plugins/test_conda_plugin.py b/tests/unit/parts/plugins/test_conda_plugin.py new file mode 100644 index 0000000000..ace3ca9f65 --- /dev/null +++ b/tests/unit/parts/plugins/test_conda_plugin.py @@ -0,0 +1,215 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License version 3 as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + +import os + +import pytest +from craft_parts import Part, PartInfo, ProjectInfo +from pydantic import ValidationError + +from snapcraft import errors +from snapcraft.parts.plugins import CondaPlugin +from snapcraft.parts.plugins.conda_plugin import _get_miniconda_source + + +@pytest.fixture(autouse=True) +def part_info(new_dir): + yield PartInfo( + project_info=ProjectInfo( + application_name="test", project_name="test-snap", cache_dir=new_dir + ), + part=Part("my-part", {}), + ) + + +@pytest.fixture +def fake_platform(monkeypatch): + if os.getenv("SNAP_ARCH"): + monkeypatch.delenv("SNAP_ARCH") + monkeypatch.setattr("platform.machine", lambda: "x86_64") + monkeypatch.setattr("platform.architecture", lambda: ("64bit", "ELF")) + + +@pytest.mark.usefixtures("fake_platform") +class TestPluginCondaPlugin: + """Conda plugin tests.""" + + def test_get_build_snaps(self, part_info): + properties = CondaPlugin.properties_class.unmarshal({}) + plugin = CondaPlugin(properties=properties, part_info=part_info) + assert plugin.get_build_snaps() == set() + + def test_get_build_packages(self, part_info): + properties = CondaPlugin.properties_class.unmarshal({}) + plugin = CondaPlugin(properties=properties, part_info=part_info) + assert plugin.get_build_packages() == set() + + def test_get_build_environment(self, part_info): + properties = CondaPlugin.properties_class.unmarshal({}) + plugin = CondaPlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_environment() == { + "PATH": "${HOME}/miniconda/bin:${PATH}" + } + + def test_get_build_commands(self, part_info): + properties = CondaPlugin.properties_class.unmarshal({}) + plugin = CondaPlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_commands() == [ + 'if ! [ -e "${HOME}/miniconda.sh" ]; then\n' + " curl --proto '=https' --tlsv1.2 -sSf " + "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh " + "> ${HOME}/miniconda.sh\n" + " chmod 755 ${HOME}/miniconda.sh\n" + "fi", + "${HOME}/miniconda.sh -bfp ${HOME}/miniconda", + ( + "CONDA_TARGET_PREFIX_OVERRIDE=/snap/test-snap/current conda create --prefix " + f"{plugin._part_info.part_install_dir!s} " + "--yes" + ), + ] + + def test_get_build_commands_conda_packages(self, part_info): + properties = CondaPlugin.properties_class.unmarshal( + {"conda-packages": ["test-package-1", "test-package-2"]} + ) + plugin = CondaPlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_commands() == [ + 'if ! [ -e "${HOME}/miniconda.sh" ]; then\n' + " curl --proto '=https' --tlsv1.2 -sSf " + "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh " + "> ${HOME}/miniconda.sh\n" + " chmod 755 ${HOME}/miniconda.sh\n" + "fi", + "${HOME}/miniconda.sh -bfp ${HOME}/miniconda", + ( + "CONDA_TARGET_PREFIX_OVERRIDE=/snap/test-snap/current conda create --prefix " + f"{plugin._part_info.part_install_dir!s} " + "--yes " + "test-package-1 test-package-2" + ), + ] + + @pytest.mark.parametrize("value", [None, []]) + def test_get_build_commands_conda_packages_empty(self, part_info, value): + properties = CondaPlugin.properties_class.unmarshal({"conda-packages": value}) + plugin = CondaPlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_commands() == [ + 'if ! [ -e "${HOME}/miniconda.sh" ]; then\n' + " curl --proto '=https' --tlsv1.2 -sSf " + "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh " + "> ${HOME}/miniconda.sh\n" + " chmod 755 ${HOME}/miniconda.sh\n" + "fi", + "${HOME}/miniconda.sh -bfp ${HOME}/miniconda", + ( + "CONDA_TARGET_PREFIX_OVERRIDE=/snap/test-snap/current conda create --prefix " + f"{plugin._part_info.part_install_dir!s} " + "--yes" + ), + ] + + @pytest.mark.parametrize( + "conda_packages", + ["i am a string", {"i am": "a dictionary"}], + ) + def test_get_build_commands_conda_packages_invalid(self, conda_packages): + with pytest.raises(ValidationError): + CondaPlugin.properties_class.unmarshal({"conda-packages": conda_packages}) + + def test_get_build_commands_conda_python_version(self, part_info): + properties = CondaPlugin.properties_class.unmarshal( + {"conda-python-version": "3.9"} + ) + plugin = CondaPlugin(properties=properties, part_info=part_info) + + assert plugin.get_build_commands() == [ + 'if ! [ -e "${HOME}/miniconda.sh" ]; then\n' + " curl --proto '=https' --tlsv1.2 -sSf " + "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh " + "> ${HOME}/miniconda.sh\n" + " chmod 755 ${HOME}/miniconda.sh\n" + "fi", + "${HOME}/miniconda.sh -bfp ${HOME}/miniconda", + ( + "CONDA_TARGET_PREFIX_OVERRIDE=/snap/test-snap/current conda create --prefix " + f"{plugin._part_info.part_install_dir!s} " + "--yes python=3.9" + ), + ] + + @pytest.mark.parametrize( + "conda_python_version", + [{"i am": "a dictionary"}, ["i am", "a list"]], + ) + def test_get_build_commands_conda_python_version_invalid( + self, conda_python_version + ): + with pytest.raises(ValidationError): + CondaPlugin.properties_class.unmarshal( + {"conda-python-version": conda_python_version} + ) + + @pytest.mark.parametrize( + "conda_install_prefix", + [{"i am": "a dictionary"}, ["i am", "a list"]], + ) + def test_get_build_commands_conda_install_prefix_invalid( + self, conda_install_prefix + ): + with pytest.raises(ValidationError): + CondaPlugin.properties_class.unmarshal( + {"conda-install-prefix": conda_install_prefix} + ) + + +@pytest.mark.parametrize( + "snap_arch, url_arch", + [ + ("i386", "x86"), + ("amd64", "x86_64"), + ("armhf", "armv7l"), + ("ppc64el", "ppc64le"), + ], +) +def test_get_miniconda(monkeypatch, snap_arch, url_arch): + monkeypatch.setenv("SNAP_ARCH", snap_arch) + + assert _get_miniconda_source("latest") == ( + f"https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-{url_arch}.sh" + ) + + +@pytest.mark.parametrize( + "snap_arch", + ("s390x", "other", "not-supported", "new-arch"), +) +def test_get_miniconda_unsupported_arch( + monkeypatch, + snap_arch, +): + monkeypatch.setenv("SNAP_ARCH", snap_arch) + + with pytest.raises(errors.SnapcraftError) as raised: + _get_miniconda_source("latest") + + assert str(raised.value) == ( + f"Architecture not supported for conda plugin: {snap_arch!r}" + ) diff --git a/tests/unit/parts/test_desktop_file.py b/tests/unit/parts/test_desktop_file.py new file mode 100644 index 0000000000..4932370d5c --- /dev/null +++ b/tests/unit/parts/test_desktop_file.py @@ -0,0 +1,236 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2019-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path +from textwrap import dedent + +import pytest + +from snapcraft import errors +from snapcraft.parts.desktop_file import DesktopFile + + +class TestDesktopExec: + """Exec entry rewriting.""" + + @pytest.mark.parametrize( + "app_name,app_args,expected_exec", + [ + # snap name == app name + ("foo", "", "foo"), + ("foo", "--arg", "foo --arg"), + ("foo", "--arg %U", "foo --arg %U"), + ("foo", "%U", "foo %U"), + + # snap name != app name + ("bar", "", "foo.bar"), + ("bar", "--arg", "foo.bar --arg"), + ] + ) + def test_generate_desktop_file( + self, new_dir, app_name, app_args, expected_exec + ): + snap_name = "foo" + + desktop_file_path = new_dir / "app.desktop" + with desktop_file_path.open("w") as desktop_file: + print("[Desktop Entry]", file=desktop_file) + print( + f"Exec={' '.join(['in-snap-exe', app_args])}", file=desktop_file + ) + + d = DesktopFile( + snap_name=snap_name, + app_name=app_name, + filename=desktop_file_path, + prime_dir=new_dir.as_posix(), + ) + d.write(gui_dir=Path()) + + expected_desktop_file = new_dir / f"{app_name}.desktop" + assert expected_desktop_file.exists() + with expected_desktop_file.open() as desktop_file: + assert desktop_file.read() == dedent( + f"""\ + [Desktop Entry] + Exec={expected_exec} + + """ + ) + + +class TestDesktopIcon: + """Icon entry rewriting.""" + + @pytest.mark.parametrize( + "icon,icon_path,expected_icon", + [ + # icon_path preferred + ("other.png", "foo.png", "${SNAP}/foo.png"), + + # icon_path with / preferred + ("/foo.png", "foo.png", "${SNAP}/foo.png"), + + # icon path with ${SNAP} + ("${SNAP}/foo.png", None, "${SNAP}/foo.png"), + + # icon name + ("foo", None, "foo"), + ] + ) + def test_generate_desktop_file(self, new_dir, icon, icon_path, expected_icon): + snap_name = app_name = "foo" + + desktop_file_path = new_dir / "app.desktop" + with desktop_file_path.open("w") as desktop_file: + print("[Desktop Entry]", file=desktop_file) + print("Exec=in-snap-exe", file=desktop_file) + print(f"Icon={icon}", file=desktop_file) + + if icon_path is not None: + (new_dir / icon_path).touch() + + d = DesktopFile( + snap_name=snap_name, + app_name=app_name, + filename=desktop_file_path, + prime_dir=new_dir, + ) + d.write(gui_dir=Path()) + + if icon_path is not None: + d.write(icon_path=icon_path, gui_dir=Path()) + else: + d.write(gui_dir=Path()) + + expected_desktop_file = new_dir / f"{app_name}.desktop" + assert expected_desktop_file.exists() + with expected_desktop_file.open() as desktop_file: + assert ( + desktop_file.read() + == dedent( + """\ + [Desktop Entry] + Exec=foo + Icon={} + + """ + ).format(expected_icon) + ) + + @pytest.mark.parametrize( + "icon,icon_path,expected_icon", + [ + # icon_path preferred + ("other.png", "foo.png", "${SNAP}/foo.png"), + + # icon_path with / preferred + ("/foo.png", "foo.png", "${SNAP}/foo.png"), + + # icon path with ${SNAP} + ("${SNAP}/foo.png", None, "${SNAP}/foo.png"), + + # icon name + ("foo", None, "foo"), + ] + ) + def test_generate_desktop_file_multisection( + self, new_dir, icon, icon_path, expected_icon + ): + snap_name = app_name = "foo" + + desktop_file_path = new_dir / "app.desktop" + with desktop_file_path.open("w") as desktop_file: + print("[Desktop Entry]", file=desktop_file) + print("Exec=in-snap-exe", file=desktop_file) + print(f"Icon={icon}", file=desktop_file) + print("[Desktop Entry Two]", file=desktop_file) + print("Exec=in-snap-exe2", file=desktop_file) + print(f"Icon={icon}", file=desktop_file) + + if icon_path is not None: + (new_dir / icon_path).touch() + + d = DesktopFile( + snap_name=snap_name, + app_name=app_name, + filename=desktop_file_path, + prime_dir=new_dir, + ) + + if icon_path is not None: + d.write(icon_path=icon_path, gui_dir=Path()) + else: + d.write(gui_dir=Path()) + + expected_desktop_file = new_dir / f"{app_name}.desktop" + assert expected_desktop_file.exists() + with expected_desktop_file.open() as desktop_file: + assert desktop_file.read() == dedent( + f"""\ + [Desktop Entry] + Exec=foo + Icon={expected_icon} + + [Desktop Entry Two] + Exec=foo + Icon={expected_icon} + + """ + ) + + +def test_not_found(new_dir): + with pytest.raises(errors.DesktopFileError): + DesktopFile( + snap_name="foo", + app_name="foo", + filename="desktop-file-not-found", + prime_dir=new_dir, + ) + + +def test_no_desktop_section(new_dir): + with open("foo.desktop", "w") as desktop_file: + print("[Random Entry]", file=desktop_file) + print("Exec=foo", file=desktop_file) + print("Icon=foo", file=desktop_file) + + d = DesktopFile( + snap_name="foo", + app_name="foo", + filename="foo.desktop", + prime_dir=new_dir, + ) + + with pytest.raises(errors.DesktopFileError): + d.write(gui_dir=new_dir) + + +def test_missing_exec_entry(new_dir): + with open("foo.desktop", "w") as desktop_file: + print("[Desktop Entry]", file=desktop_file) + print("Icon=foo", file=desktop_file) + + d = DesktopFile( + snap_name="foo", + app_name="foo", + filename="foo.desktop", + prime_dir=new_dir, + ) + + with pytest.raises(errors.DesktopFileError): + d.write(gui_dir=new_dir) diff --git a/tests/unit/parts/test_grammar.py b/tests/unit/parts/test_grammar.py new file mode 100644 index 0000000000..856d2bfd20 --- /dev/null +++ b/tests/unit/parts/test_grammar.py @@ -0,0 +1,153 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Grammar processor tests.""" + +from collections import namedtuple + +import pytest +from craft_grammar import GrammarProcessor + +from snapcraft.parts.grammar import process_part, process_parts + +_PROCESSOR = GrammarProcessor( + arch="amd64", + target_arch="amd64", + checker=lambda x: x == x, # pylint: disable=comparison-with-itself +) +GrammarEntry = namedtuple("GrammarEntry", ["value", "expected"]) + +GRAMMAR_SCALAR_ENTRIES = [ + # no grammar. + GrammarEntry("entry", "entry"), + # on arch match. + GrammarEntry([{"on amd64": "entry"}], "entry"), + # on else match. + GrammarEntry([{"on arm64": "entry"}, {"else": "else-entry"}], "else-entry"), + # on other-arch no else. + GrammarEntry([{"on arm64": "entry"}], None), + # TODO: on to match +] + + +@pytest.mark.parametrize("grammar_entry", GRAMMAR_SCALAR_ENTRIES) +@pytest.mark.parametrize("key", ["source"]) +def test_scalar_values(key, grammar_entry): + part_yaml_data = {key: grammar_entry.value} + + value = process_part(part_yaml_data=part_yaml_data, processor=_PROCESSOR) + + expected = {key: grammar_entry.expected} + assert value == expected + + +GRAMMAR_LIST_ENTRIES = [ + # no grammar. + GrammarEntry(["entry"], ["entry"]), + # on arch match. + GrammarEntry([{"on amd64": ["entry"]}], ["entry"]), + # on else match. + GrammarEntry([{"on arm64": ["entry"]}, {"else": ["else-entry"]}], ["else-entry"]), + # on other-arch no else. + GrammarEntry([{"on arm64": ["entry"]}], []), + # TODO: on to match +] + + +@pytest.mark.parametrize("grammar_entry", GRAMMAR_LIST_ENTRIES) +@pytest.mark.parametrize( + "key", + [ + "build-environment", + "build-packages", + "stage-packages", + "build-snaps", + "stage-snaps", + ], +) +def test_list_values(key, grammar_entry): + part_yaml_data = {key: grammar_entry.value} + + value = process_part(part_yaml_data=part_yaml_data, processor=_PROCESSOR) + + expected = {key: grammar_entry.expected} + assert value == expected + + +def test_process_grammar(): + assert process_parts( + parts_yaml_data={ + "no-grammar": { + "source": "source-foo", + "build-environment": ["env-foo"], + "build-packages": ["build-pkg-foo"], + "stage-packages": ["stage-pkg-foo"], + "build-snaps": ["build-snap-foo"], + "stage-snaps": ["stage-snap-foo"], + }, + "grammar": { + "source": [ + { + "on amd64": "source-foo", + }, + ], + "build-environment": [ + { + "on amd64": ["env-foo"], + }, + ], + "build-packages": [ + { + "on amd64": ["build-pkg-foo"], + }, + ], + "stage-packages": [ + { + "on amd64": ["stage-pkg-foo"], + }, + ], + "build-snaps": [ + { + "on amd64": ["build-snap-foo"], + }, + ], + "stage-snaps": [ + { + "on amd64": ["stage-snap-foo"], + }, + ], + }, + }, + arch="amd64", + target_arch="amd64", + ) == { + "no-grammar": { + "source": "source-foo", + "build-environment": ["env-foo"], + "build-packages": ["build-pkg-foo"], + "stage-packages": ["stage-pkg-foo"], + "build-snaps": ["build-snap-foo"], + "stage-snaps": ["stage-snap-foo"], + }, + "grammar": { + "source": "source-foo", + "build-environment": ["env-foo"], + "build-packages": ["build-pkg-foo"], + "stage-packages": ["stage-pkg-foo"], + "build-snaps": ["build-snap-foo"], + "stage-snaps": ["stage-snap-foo"], + }, + } diff --git a/tests/unit/parts/test_lifecycle.py b/tests/unit/parts/test_lifecycle.py new file mode 100644 index 0000000000..eca7c4e724 --- /dev/null +++ b/tests/unit/parts/test_lifecycle.py @@ -0,0 +1,779 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import argparse +import textwrap +from pathlib import Path +from typing import Any, Dict +from unittest.mock import PropertyMock, call + +import pytest +from craft_parts import Action, Step + +from snapcraft import errors +from snapcraft.parts import lifecycle as parts_lifecycle +from snapcraft.parts.update_metadata import update_project_metadata +from snapcraft.projects import MANDATORY_ADOPTABLE_FIELDS, Project + +_SNAPCRAFT_YAML_FILENAMES = [ + "snap/snapcraft.yaml", + "build-aux/snap/snapcraft.yaml", + "snapcraft.yaml", + ".snapcraft.yaml", +] + + +@pytest.fixture(autouse=True) +def disable_install(mocker): + mocker.patch("craft_parts.packages.Repository.install_packages") + mocker.patch("craft_parts.packages.snaps.install_snaps") + + +@pytest.fixture +def snapcraft_yaml(new_dir): + def write_file( + *, base: str, filename: str = "snap/snapcraft.yaml" + ) -> Dict[str, Any]: + content = textwrap.dedent( + f""" + name: mytest + version: '0.1' + base: {base} + summary: Just some test data + description: This is just some test data. + grade: stable + confinement: strict + + parts: + part1: + plugin: nil + """ + ) + yaml_path = Path(filename) + yaml_path.parent.mkdir(parents=True, exist_ok=True) + yaml_path.write_text(content) + + return { + "name": "mytest", + "title": None, + "base": base, + "compression": "xz", + "version": "0.1", + "contact": None, + "donation": None, + "issues": None, + "source-code": None, + "website": None, + "summary": "Just some test data", + "description": "This is just some test data.", + "type": None, + "confinement": "strict", + "icon": None, + "layout": None, + "license": None, + "grade": "stable", + "architectures": [], + "package-repositories": [], + "assumes": [], + "hooks": None, + "passthrough": None, + "apps": None, + "plugs": None, + "slots": None, + "parts": {"part1": {"plugin": "nil"}}, + "epoch": None, + } + + yield write_file + + +@pytest.fixture +def project_vars(mocker): + yield mocker.patch( + "snapcraft.parts.PartsLifecycle.project_vars", + new_callable=PropertyMock, + return_value={"version": "0.1", "grade": "stable"}, + ) + + +def test_config_not_found(new_dir): + """If snapcraft.yaml is not found, raise an error.""" + with pytest.raises(errors.SnapcraftError) as raised: + parts_lifecycle.run("pull", argparse.Namespace()) + + assert str(raised.value) == ( + "Could not find snap/snapcraft.yaml. Are you sure you are in the right " + "directory?" + ) + assert raised.value.resolution == "To start a new project, use `snapcraft init`" + + +@pytest.mark.parametrize("filename", _SNAPCRAFT_YAML_FILENAMES) +def test_snapcraft_yaml_load(new_dir, snapcraft_yaml, filename, mocker): + """Snapcraft.yaml should be parsed as a valid yaml file.""" + yaml_data = snapcraft_yaml(base="core22", filename=filename) + run_command_mock = mocker.patch("snapcraft.parts.lifecycle._run_command") + + parts_lifecycle.run( + "pull", + argparse.Namespace( + parts=["part1"], destructive_mode=True, use_lxd=False, provider=None + ), + ) + + project = Project.unmarshal(yaml_data) + + if filename == "build-aux/snap/snapcraft.yaml": + assets_dir = Path("build-aux/snap") + else: + assets_dir = Path("snap") + + assert run_command_mock.mock_calls == [ + call( + "pull", + project=project, + parse_info={}, + assets_dir=assets_dir, + parsed_args=argparse.Namespace( + parts=["part1"], destructive_mode=True, use_lxd=False, provider=None + ), + ), + ] + + +@pytest.mark.parametrize( + "cmd", ["pull", "build", "stage", "prime", "pack", "snap", "clean"] +) +def test_lifecycle_run_provider(cmd, snapcraft_yaml, new_dir, mocker): + """Option --provider is not supported in core22.""" + snapcraft_yaml(base="core22") + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + + with pytest.raises(errors.SnapcraftError) as raised: + parts_lifecycle.run( + cmd, + parsed_args=argparse.Namespace( + destructive_mode=False, + use_lxd=False, + provider="some", + ), + ) + + assert run_mock.mock_calls == [] + assert str(raised.value) == "Option --provider is not supported." + + +@pytest.mark.parametrize("cmd", ["pull", "build", "stage", "prime", "snap", "clean"]) +def test_lifecycle_legacy_run_provider(cmd, snapcraft_yaml, new_dir, mocker): + """Option --provider is supported by legacy.""" + snapcraft_yaml(base="core20") + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + + with pytest.raises(errors.LegacyFallback) as raised: + parts_lifecycle.run( + cmd, + parsed_args=argparse.Namespace( + destructive_mode=False, + use_lxd=False, + provider="some", + ), + ) + + assert run_mock.mock_calls == [] + assert str(raised.value) == "base is not core22" + + +@pytest.mark.parametrize( + "cmd,step", + [ + ("pull", "pull"), + ("build", "build"), + ("stage", "stage"), + ("prime", "prime"), + ], +) +@pytest.mark.parametrize("debug_shell", [None, "debug", "shell", "shell_after"]) +def test_lifecycle_run_command_step( + cmd, step, debug_shell, snapcraft_yaml, project_vars, new_dir, mocker +): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + mocker.patch("snapcraft.meta.snap_yaml.write") + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + + parsed_args = argparse.Namespace( + debug=False, + destructive_mode=True, + shell=False, + shell_after=False, + use_lxd=False, + parts=[], + ) + + if debug_shell: + setattr(parsed_args, debug_shell, True) + + parts_lifecycle._run_command( + cmd, project=project, parse_info={}, assets_dir=Path(), parsed_args=parsed_args + ) + + call_args = {"debug": False, "shell": False, "shell_after": False} + if debug_shell: + call_args[debug_shell] = True + + assert run_mock.mock_calls == [call(step, **call_args)] + assert pack_mock.mock_calls == [] + + +@pytest.mark.parametrize("cmd", ["pack", "snap"]) +def test_lifecycle_run_command_pack(cmd, snapcraft_yaml, project_vars, new_dir, mocker): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + mocker.patch("snapcraft.meta.snap_yaml.write") + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=True, + shell=False, + shell_after=False, + use_lxd=False, + parts=[], + ), + ) + + assert run_mock.mock_calls == [ + call("prime", debug=False, shell=False, shell_after=False) + ] + assert pack_mock.mock_calls == [ + call(new_dir / "prime", output=None, compression="xz") + ] + + +@pytest.mark.parametrize("cmd", ["pack", "snap"]) +def test_lifecycle_pack_destructive_mode( + cmd, snapcraft_yaml, project_vars, new_dir, mocker +): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider") + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + mocker.patch("snapcraft.meta.snap_yaml.write") + mocker.patch("snapcraft.utils.is_managed_mode", return_value=True) + mocker.patch( + "snapcraft.utils.get_managed_environment_home_path", + return_value=new_dir / "home", + ) + + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=True, + shell=False, + shell_after=False, + use_lxd=False, + parts=[], + ), + ) + + assert run_in_provider_mock.mock_calls == [] + assert run_mock.mock_calls == [ + call("prime", debug=False, shell=False, shell_after=False) + ] + assert pack_mock.mock_calls == [ + call(new_dir / "home/prime", output=None, compression="xz") + ] + + +@pytest.mark.parametrize("cmd", ["pack", "snap"]) +def test_lifecycle_pack_managed(cmd, snapcraft_yaml, project_vars, new_dir, mocker): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider") + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + mocker.patch("snapcraft.meta.snap_yaml.write") + mocker.patch("snapcraft.utils.is_managed_mode", return_value=True) + mocker.patch( + "snapcraft.utils.get_managed_environment_home_path", + return_value=new_dir / "home", + ) + + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=False, + shell=False, + shell_after=False, + use_lxd=False, + parts=[], + ), + ) + + assert run_in_provider_mock.mock_calls == [] + assert run_mock.mock_calls == [ + call("prime", debug=False, shell=False, shell_after=False) + ] + assert pack_mock.mock_calls == [ + call(new_dir / "home/prime", output=None, compression="xz") + ] + + +@pytest.mark.parametrize("cmd", ["pack", "snap"]) +def test_lifecycle_pack_not_managed(cmd, snapcraft_yaml, new_dir, mocker): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider") + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + mocker.patch("snapcraft.utils.is_managed_mode", return_value=False) + + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + parts=[], + ), + ) + + assert run_mock.mock_calls == [] + assert run_in_provider_mock.mock_calls == [ + call( + project, + cmd, + argparse.Namespace( + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + parts=[], + ), + ) + ] + + +@pytest.mark.parametrize("cmd", ["pack", "snap"]) +def test_lifecycle_pack_metadata_error(cmd, snapcraft_yaml, new_dir, mocker): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_mock = mocker.patch("snapcraft.parts.PartsLifecycle.run") + mocker.patch("snapcraft.utils.is_managed_mode", return_value=True) + mocker.patch( + "snapcraft.utils.get_managed_environment_home_path", + return_value=new_dir / "home", + ) + mocker.patch( + "snapcraft.parts.PartsLifecycle.project_vars", + new_callable=PropertyMock, + return_value={"version": "0.1", "grade": "invalid"}, # invalid value + ) + pack_mock = mocker.patch("snapcraft.pack.pack_snap") + mocker.patch("snapcraft.meta.snap_yaml.write") + + with pytest.raises(errors.SnapcraftError) as raised: + parts_lifecycle._run_command( + cmd, + project=project, + assets_dir=Path(), + parse_info={}, + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=False, + shell=False, + shell_after=False, + use_lxd=False, + parts=[], + ), + ) + + assert str(raised.value) == ( + "error setting grade: unexpected value; permitted: 'stable', 'devel'" + ) + assert run_mock.mock_calls == [ + call("prime", debug=False, shell=False, shell_after=False) + ] + assert pack_mock.mock_calls == [] + + +@pytest.mark.parametrize("field", MANDATORY_ADOPTABLE_FIELDS) +def test_lifecycle_metadata_empty(field, snapcraft_yaml, new_dir): + """Adoptable fields shouldn't be empty after adoption.""" + yaml_data = snapcraft_yaml(base="core22") + yaml_data.pop(field) + yaml_data["adopt-info"] = "part" + project = Project.unmarshal(yaml_data) + + with pytest.raises(errors.SnapcraftError) as raised: + update_project_metadata( + project, + project_vars={"version": "", "grade": ""}, + metadata_list=[], + assets_dir=new_dir, + prime_dir=new_dir, + ) + + assert str(raised.value) == f"Field {field!r} was not adopted from metadata" + + +def test_lifecycle_run_command_clean(snapcraft_yaml, project_vars, new_dir, mocker): + """Clean provider project when called without parts.""" + project = Project.unmarshal(snapcraft_yaml(base="core22")) + clean_mock = mocker.patch( + "snapcraft.providers.LXDProvider.clean_project_environments", + return_value=["instance-name"], + ) + + parts_lifecycle._run_command( + "clean", + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + parts=None, + ), + ) + + assert clean_mock.mock_calls == [call(project_name="mytest", project_path=new_dir)] + + +def test_lifecycle_clean_destructive_mode( + snapcraft_yaml, project_vars, new_dir, mocker +): + """Clean local project if called in destructive mode.""" + project = Project.unmarshal(snapcraft_yaml(base="core22")) + clean_mock = mocker.patch("snapcraft.parts.PartsLifecycle.clean") + + parts_lifecycle._run_command( + "clean", + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + destructive_mode=True, + use_lxd=False, + parts=None, + ), + ) + + assert clean_mock.mock_calls == [call(part_names=None)] + + +def test_lifecycle_clean_part_names(snapcraft_yaml, project_vars, new_dir, mocker): + """Clean project inside provider if called with part names.""" + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider") + + parts_lifecycle._run_command( + "clean", + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + parts=["part1"], + ), + ) + + assert run_in_provider_mock.mock_calls == [ + call( + project, + "clean", + argparse.Namespace( + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + parts=["part1"], + ), + ) + ] + + +def test_lifecycle_clean_part_names_destructive_mode( + snapcraft_yaml, project_vars, new_dir, mocker +): + """Clean local project if called in destructive mode.""" + project = Project.unmarshal(snapcraft_yaml(base="core22")) + clean_mock = mocker.patch("snapcraft.parts.PartsLifecycle.clean") + + parts_lifecycle._run_command( + "clean", + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + destructive_mode=True, + use_lxd=False, + parts=["part1"], + ), + ) + + assert clean_mock.mock_calls == [call(part_names=["part1"])] + + +def test_lifecycle_clean_managed(snapcraft_yaml, project_vars, new_dir, mocker): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + run_in_provider_mock = mocker.patch("snapcraft.parts.lifecycle._run_in_provider") + clean_mock = mocker.patch("snapcraft.parts.PartsLifecycle.clean") + mocker.patch("snapcraft.utils.is_managed_mode", return_value=True) + mocker.patch( + "snapcraft.utils.get_managed_environment_home_path", + return_value=new_dir / "home", + ) + + parts_lifecycle._run_command( + "clean", + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + destructive_mode=False, + use_lxd=False, + parts=["part1"], + ), + ) + + assert run_in_provider_mock.mock_calls == [] + assert clean_mock.mock_calls == [call(part_names=["part1"])] + + +@pytest.mark.parametrize("cmd", ["pull", "build", "stage", "prime", "pack", "snap"]) +def test_lifecycle_debug_shell(snapcraft_yaml, cmd, new_dir, mocker): + """Adoptable fields shouldn't be empty after adoption.""" + mocker.patch("craft_parts.executor.Executor.execute", side_effect=Exception) + mock_shell = mocker.patch("subprocess.run") + project = Project.unmarshal(snapcraft_yaml(base="core22")) + + with pytest.raises(errors.PartsLifecycleError): + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=True, + destructive_mode=True, + shell=False, + shell_after=False, + use_lxd=False, + parts=["part1"], + ), + ) + + assert mock_shell.mock_calls == [call(["bash"], check=False, cwd=None)] + + +@pytest.mark.parametrize("cmd", ["pull", "build", "stage", "prime"]) +def test_lifecycle_shell(snapcraft_yaml, cmd, new_dir, mocker): + """Adoptable fields shouldn't be empty after adoption.""" + last_step = None + + def _fake_execute(_, action: Action, **kwargs): # pylint: disable=unused-argument + nonlocal last_step + last_step = action.step + + mocker.patch("craft_parts.executor.Executor.execute", new=_fake_execute) + mock_shell = mocker.patch("subprocess.run") + project = Project.unmarshal(snapcraft_yaml(base="core22")) + + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=True, + shell=True, + shell_after=False, + use_lxd=False, + parts=["part1"], + ), + ) + + expected_last_step = None + if cmd == "build": + expected_last_step = Step.OVERLAY + if cmd == "stage": + expected_last_step = Step.BUILD + if cmd == "prime": + expected_last_step = Step.STAGE + + assert last_step == expected_last_step + assert mock_shell.mock_calls == [call(["bash"], check=False, cwd=None)] + + +@pytest.mark.parametrize("cmd", ["pull", "build", "stage", "prime"]) +def test_lifecycle_shell_after(snapcraft_yaml, cmd, new_dir, mocker): + """Adoptable fields shouldn't be empty after adoption.""" + last_step = None + + def _fake_execute(_, action: Action, **kwargs): # pylint: disable=unused-argument + nonlocal last_step + last_step = action.step + + mocker.patch("craft_parts.executor.Executor.execute", new=_fake_execute) + mock_shell = mocker.patch("subprocess.run") + project = Project.unmarshal(snapcraft_yaml(base="core22")) + + parts_lifecycle._run_command( + cmd, + project=project, + parse_info={}, + assets_dir=Path(), + parsed_args=argparse.Namespace( + directory=None, + output=None, + debug=False, + destructive_mode=True, + shell=False, + shell_after=True, + use_lxd=False, + parts=["part1"], + ), + ) + + expected_last_step = Step.PULL + if cmd == "build": + expected_last_step = Step.BUILD + if cmd == "stage": + expected_last_step = Step.STAGE + if cmd == "prime": + expected_last_step = Step.PRIME + + assert last_step == expected_last_step + assert mock_shell.mock_calls == [call(["bash"], check=False, cwd=None)] + + +def test_lifecycle_adopt_project_vars(snapcraft_yaml, new_dir): + """Adoptable fields shouldn't be empty after adoption.""" + yaml_data = snapcraft_yaml(base="core22") + yaml_data.pop("version") + yaml_data.pop("grade") + yaml_data["adopt-info"] = "part" + project = Project.unmarshal(yaml_data) + + update_project_metadata( + project, + project_vars={"version": "42", "grade": "devel"}, + metadata_list=[], + assets_dir=new_dir, + prime_dir=new_dir, + ) + + assert project.version == "42" + assert project.grade == "devel" + + +def test_extract_parse_info(): + yaml_data = { + "name": "foo", + "parts": {"p1": {"plugin": "nil", "parse-info": "foo/metadata.xml"}, "p2": {}}, + } + parse_info = parts_lifecycle._extract_parse_info(yaml_data) + assert yaml_data == {"name": "foo", "parts": {"p1": {"plugin": "nil"}, "p2": {}}} + assert parse_info == {"p1": "foo/metadata.xml"} + + +def test_get_snap_project_no_base(snapcraft_yaml, new_dir): + with pytest.raises(errors.ProjectValidationError) as raised: + Project.unmarshal(snapcraft_yaml(base=None)) + + assert str(raised.value) == ( + "Bad snapcraft.yaml content:\n" + "- Snap base must be declared when type is not base, kernel or snapd" + ) + + +def test_get_snap_project_with_base(snapcraft_yaml): + project = Project.unmarshal(snapcraft_yaml(base="core22")) + + assert parts_lifecycle._get_extra_build_snaps(project) == ["core22"] + + +def test_get_snap_project_with_content_plugs(snapcraft_yaml, new_dir): + yaml_data = { + "name": "mytest", + "version": "0.1", + "base": "core22", + "summary": "Just some test data", + "description": "This is just some test data.", + "grade": "stable", + "confinement": "strict", + "parts": {"part1": {"plugin": "nil"}}, + "plugs": { + "test-plug-1": { + "content": "content-interface", + "interface": "content", + "target": "$SNAP/content", + "default-provider": "test-snap-1", + }, + "test-plug-2": { + "content": "content-interface", + "interface": "content", + "target": "$SNAP/content", + "default-provider": "test-snap-2", + }, + }, + } + + project = Project(**yaml_data) + + assert parts_lifecycle._get_extra_build_snaps(project) == [ + "test-snap-1", + "test-snap-2", + "core22", + ] diff --git a/tests/unit/parts/test_parts.py b/tests/unit/parts/test_parts.py new file mode 100644 index 0000000000..f058c78e04 --- /dev/null +++ b/tests/unit/parts/test_parts.py @@ -0,0 +1,192 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pathlib import Path +from unittest.mock import ANY, call + +import craft_parts +import pytest + +from snapcraft import errors +from snapcraft.parts import PartsLifecycle + + +@pytest.fixture +def parts_data(): + yield { + "p1": {"plugin": "nil"}, + } + + +@pytest.mark.parametrize("step_name", ["pull", "overlay", "build", "stage", "prime"]) +def test_parts_lifecycle_run(mocker, parts_data, step_name, new_dir, emitter): + mocker.patch("craft_parts.executor.executor.Executor._install_build_snaps") + lcm_spy = mocker.spy(craft_parts, "LifecycleManager") + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + extra_build_snaps=["core22"], + ) + lifecycle.run(step_name) + assert lifecycle.prime_dir == Path(new_dir, "prime") + assert lifecycle.prime_dir.is_dir() + assert lcm_spy.mock_calls == [ + call( + {"parts": {"p1": {"plugin": "nil"}}}, + application_name="snapcraft", + work_dir=ANY, + cache_dir=ANY, + ignore_local_sources=["*.snap"], + extra_build_packages=[], + extra_build_snaps=["core22"], + project_name="test-project", + project_vars_part_name=None, + project_vars={"version": "1", "grade": "stable"}, + ) + ] + emitter.assert_progress(f"Executing parts lifecycle: {step_name} p1") + + +def test_parts_lifecycle_run_bad_step(parts_data, new_dir): + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[], + adopt_info=None, + parse_info={}, + project_name="test-project", + project_vars={"version": "1", "grade": "stable"}, + ) + with pytest.raises(RuntimeError) as raised: + lifecycle.run("invalid") + assert str(raised.value) == "Invalid target step 'invalid'" + + +def test_parts_lifecycle_run_internal_error(parts_data, new_dir, mocker): + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + ) + mocker.patch("craft_parts.LifecycleManager.plan", side_effect=RuntimeError("crash")) + with pytest.raises(RuntimeError) as raised: + lifecycle.run("prime") + assert str(raised.value) == "Parts processing internal error: crash" + + +def test_parts_lifecycle_run_parts_error(new_dir): + lifecycle = PartsLifecycle( + {"p1": {"plugin": "dump", "source": "foo"}}, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + ) + with pytest.raises(errors.PartsLifecycleError) as raised: + lifecycle.run("prime") + assert str(raised.value) == ( + "Failed to pull source: unable to determine source type of 'foo'." + ) + + +def test_parts_lifecycle_clean(parts_data, new_dir, emitter): + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + ) + lifecycle.clean(part_names=None) + emitter.assert_message("Cleaning all parts", intermediate=True) + + +def test_parts_lifecycle_clean_parts(parts_data, new_dir, emitter): + lifecycle = PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + ) + lifecycle.clean(part_names=["p1"]) + emitter.assert_message("Cleaning parts: p1", intermediate=True) + + +def test_parts_lifecycle_initialize_with_package_repositories( + mocker, + parts_data, + new_dir, +): + lcm_spy = mocker.spy(craft_parts, "LifecycleManager") + PartsLifecycle( + parts_data, + work_dir=new_dir, + assets_dir=new_dir, + part_names=[], + package_repositories=[ + { + "type": "apt", + "ppa": "test/somerepo", + }, + ], + adopt_info=None, + project_name="test-project", + parse_info={}, + project_vars={"version": "1", "grade": "stable"}, + extra_build_snaps=["core22"], + ) + assert lcm_spy.mock_calls == [ + call( + {"parts": {"p1": {"plugin": "nil"}}}, + application_name="snapcraft", + work_dir=ANY, + cache_dir=ANY, + ignore_local_sources=["*.snap"], + extra_build_packages=["gnupg", "dirmngr"], + extra_build_snaps=["core22"], + project_name="test-project", + project_vars_part_name=None, + project_vars={"version": "1", "grade": "stable"}, + ) + ] diff --git a/tests/unit/parts/test_setup_assets.py b/tests/unit/parts/test_setup_assets.py new file mode 100644 index 0000000000..5fe5ef3eb5 --- /dev/null +++ b/tests/unit/parts/test_setup_assets.py @@ -0,0 +1,249 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2017-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import textwrap +from pathlib import Path +from typing import Any, Dict + +import pytest + +from snapcraft import errors +from snapcraft.parts.setup_assets import _validate_command_chain, setup_assets +from snapcraft.projects import Project + + +@pytest.fixture +def desktop_file(): + def _write_file(filename: str): + Path(filename).write_text( + textwrap.dedent( + """\ + [Desktop Entry] + Name=appstream-desktop + Exec=appstream + Type=Application + Icon=/usr/share/icons/my-icon.svg""" + ) + ) + + yield _write_file + + +@pytest.fixture +def yaml_data(): + def _yaml_data(extra_data: Dict[str, Any]) -> Dict[str, Any]: + return { + "name": "test-project", + "base": "core22", + "confinement": "strict", + "parts": {}, + **extra_data, + } + + yield _yaml_data + + +class TestSetupAssets: + """Check copied assets and desktop entries.""" + + @pytest.fixture(autouse=True) + def setup_method_fixture(self, new_dir): + # create prime tree + Path("prime").mkdir() + Path("prime/test.sh").touch() + Path("prime/test.sh").chmod(0o755) + + # create assets dir + Path("snap").mkdir() + + def test_setup_assets_happy(self, desktop_file, yaml_data, new_dir): + desktop_file("prime/test.desktop") + Path("prime/usr/share/icons").mkdir(parents=True) + Path("prime/usr/share/icons/my-icon.svg").touch() + + # define project + project = Project.unmarshal( + yaml_data( + { + "adopt-info": "part", + "apps": { + "app1": { + "command": "test.sh", + "common-id": "my-test", + "desktop": "test.desktop", + }, + }, + }, + ) + ) + + setup_assets(project, assets_dir=Path("snap"), prime_dir=Path("prime")) + + # desktop file should be in meta/gui and named after app + desktop_path = Path("prime/meta/gui/app1.desktop") + assert desktop_path.is_file() + + # desktop file content should make icon relative to ${SNAP} + content = desktop_path.read_text() + assert content == textwrap.dedent( + """\ + [Desktop Entry] + Name=appstream-desktop + Exec=test-project.app1 + Type=Application + Icon=${SNAP}/usr/share/icons/my-icon.svg + + """ + ) + + def test_setup_assets_icon_in_assets_dir(self, desktop_file, yaml_data, new_dir): + desktop_file("prime/test.desktop") + Path("snap/gui").mkdir(parents=True) + Path("snap/gui/icon.svg").touch() + + # define project + project = Project.unmarshal( + yaml_data( + { + "adopt-info": "part", + "apps": { + "app1": { + "command": "test.sh", + "common-id": "my-test", + "desktop": "test.desktop", + }, + }, + }, + ) + ) + + setup_assets(project, assets_dir=Path("snap"), prime_dir=Path("prime")) + + # desktop file should be in meta/gui and named after app + desktop_path = Path("prime/meta/gui/app1.desktop") + assert desktop_path.is_file() + + # desktop file content should make icon relative to ${SNAP} + content = desktop_path.read_text() + assert content == textwrap.dedent( + """\ + [Desktop Entry] + Name=appstream-desktop + Exec=test-project.app1 + Type=Application + Icon=${SNAP}/snap/gui/icon.svg + + """ + ) + + # icon file exists + Path("prime/snap/gui/icon.svg").is_file() + + def test_setup_assets_no_apps(self, desktop_file, yaml_data, new_dir): + desktop_file("prime/test.desktop") + Path("prime/usr/share/icons").mkdir(parents=True) + Path("prime/usr/share/icons/icon.svg").touch() + Path("snap/gui").mkdir() + + # define project + project = Project.unmarshal(yaml_data({"adopt-info": "part"})) + + # setting up assets does not crash + setup_assets(project, assets_dir=Path("snap"), prime_dir=Path("prime")) + + assert os.listdir("prime/meta/gui") == [] + + def test_setup_assets_remote_icon(self, desktop_file, yaml_data, new_dir): + # create primed tree (no icon) + desktop_file("prime/test.desktop") + + # define project + # pylint: disable=line-too-long + project = Project.unmarshal( + yaml_data( + { + "adopt-info": "part", + "icon": "https://dashboard.snapcraft.io/site_media/appmedia/2018/04/Snapcraft-logo-bird.png", + "apps": { + "app1": { + "command": "test.sh", + "common-id": "my-test", + "desktop": "test.desktop", + }, + }, + }, + ) + ) + # pylint: enable=line-too-long + + setup_assets(project, assets_dir=Path("snap"), prime_dir=Path("prime")) + + # desktop file should be in meta/gui and named after app + desktop_path = Path("prime/meta/gui/app1.desktop") + assert desktop_path.is_file() + + # desktop file content should make icon relative to ${SNAP} + content = desktop_path.read_text() + assert content == textwrap.dedent( + """\ + [Desktop Entry] + Name=appstream-desktop + Exec=test-project.app1 + Type=Application + Icon=${SNAP}/meta/gui/icon.png + + """ + ) + + # icon was downloaded + icon_path = Path("prime/meta/gui/icon.png") + assert icon_path.is_file() + assert icon_path.stat().st_size > 0 + + +class TestCommandChain: + """Command chain items are valid.""" + + def test_command_chain_path_not_found(self, new_dir): + + with pytest.raises(errors.SnapcraftError) as raised: + _validate_command_chain( + ["file-not-found"], app_name="foo", prime_dir=new_dir + ) + + assert str(raised.value) == ( + "Failed to generate snap metadata: The command-chain item 'file-not-found' " + "defined in the app 'foo' does not exist or is not executable." + ) + + def test_command_chain_path_not_executable(self, new_dir): + Path("file-executable").touch() + Path("file-executable").chmod(0o755) + + Path("file-not-executable").touch() + + with pytest.raises(errors.SnapcraftError) as raised: + _validate_command_chain( + ["file-executable", "file-not-executable"], + app_name="foo", + prime_dir=new_dir, + ) + + assert str(raised.value) == ( + "Failed to generate snap metadata: The command-chain item 'file-not-executable' " + "defined in the app 'foo' does not exist or is not executable." + ) diff --git a/tests/unit/parts/test_update_metadata.py b/tests/unit/parts/test_update_metadata.py new file mode 100644 index 0000000000..51977bcd38 --- /dev/null +++ b/tests/unit/parts/test_update_metadata.py @@ -0,0 +1,560 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import textwrap +from pathlib import Path +from typing import Any, Dict + +import pytest + +from snapcraft.meta import ExtractedMetadata +from snapcraft.parts.update_metadata import update_project_metadata +from snapcraft.projects import App, Project + + +@pytest.fixture +def appstream_file(new_dir): + content = textwrap.dedent( + """ + + + io.snapcraft.snapcraft + CC0-1.0 + GPL-3.0 + snapcraft + snapcraft + Create snaps + Crea snaps + +

      Command Line Utility to create snaps.

      +

      Aplicativo de línea de comandos para crear snaps.

      +

      Features:

      +

      Funciones:

      +
        +
      1. Build snaps.
      2. +
      3. Construye snaps.
      4. +
      5. Publish snaps to the store.
      6. +
      7. Publica snaps en la tienda.
      8. +
      +
      + + snapcraft + +
      + """ + ) + yaml_path = Path("appstream.appdata.xml") + yaml_path.parent.mkdir(parents=True, exist_ok=True) + yaml_path.write_text(content) + + +@pytest.fixture +def project_yaml_data(): + def yaml_data(extra_args: Dict[str, Any]): + return { + "name": "name", + "summary": "summary", + "description": "description", + "base": "core22", + "grade": "stable", + "confinement": "strict", + "parts": {}, + **extra_args, + } + + yield yaml_data + + +def _project_app(data: Dict[str, Any]) -> App: + return App(**data) + + +def test_update_project_metadata(project_yaml_data, appstream_file, new_dir): + project = Project.unmarshal(project_yaml_data({"adopt-info": "part"})) + metadata = ExtractedMetadata( + common_id="common.id", + title="title", + summary="summary", + description="description", + version="1.2.3", + icon="assets/icon.png", + desktop_file_paths=["assets/file.desktop"], + ) + assets_dir = Path("assets") + prime_dir = Path("prime") + + # set up project apps + project.apps = { + "app1": _project_app({"command": "bin/app1"}), + "app2": _project_app({"command": "bin/app2", "common_id": "other.id"}), + "app3": _project_app({"command": "bin/app3", "common_id": "common.id"}), + } + + prime_dir.mkdir() + (prime_dir / "assets").mkdir() + (prime_dir / "assets/icon.png").touch() + (prime_dir / "assets/file.desktop").touch() + + prj_vars = {"version": "0.1", "grade": "stable"} + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=assets_dir, + prime_dir=prime_dir, + ) + + assert project.title == "title" + assert project.summary == "summary" # already set in project + assert project.description == "description" # already set in project + assert project.version == "0.1" # already set in project + assert project.icon == "assets/icon.png" + assert project.apps["app3"].desktop == "assets/file.desktop" + + +@pytest.mark.parametrize( + "project_entries,expected", + [ + ( + { + "version": "1.2.3", + "summary": "project summary", + "description": "project description", + "title": "project title", + "grade": "stable", + }, + { + "version": "1.2.3", + "summary": "project summary", + "description": "project description", + "title": "project title", + "grade": "stable", + }, + ), + ( + {}, + { + "version": "4.5.6", + "summary": "metadata summary", + "description": "metadata description", + "title": "metadata title", + "grade": "devel", + }, + ), + ], +) +def test_update_project_metadata_fields( + appstream_file, project_entries, expected, new_dir +): + yaml_data = { + "name": "my-project", + "base": "core22", + "confinement": "strict", + "adopt-info": "part", + "parts": {}, + **project_entries, + } + project = Project(**yaml_data) + metadata = ExtractedMetadata( + version="4.5.6", + summary="metadata summary", + description="metadata description", + title="metadata title", + grade="devel", + ) + prj_vars = {"version": "", "grade": ""} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=new_dir, + prime_dir=new_dir, + ) + + assert project.version == expected["version"] + assert project.summary == expected["summary"] + assert project.description == expected["description"] + assert project.title == expected["title"] + assert project.grade == expected["grade"] + + +@pytest.mark.parametrize( + "project_entries,expected", + [ + ( + { + "version": "1.2.3", + "summary": "project summary", + "description": "project description", + "title": "project title", + "grade": "stable", + }, + { + "version": "1.2.3", + "summary": "project summary", + "description": "project description", + "title": "project title", + "grade": "stable", + }, + ), + ( + {}, + { + "version": "4.5.6", + "summary": "metadata summary", + "description": "metadata description", + "title": "metadata title", + "grade": "devel", + }, + ), + ], +) +def test_update_project_metadata_multiple( + appstream_file, project_entries, expected, new_dir +): + yaml_data = { + "name": "my-project", + "base": "core22", + "confinement": "strict", + "adopt-info": "part", + "parts": {}, + **project_entries, + } + project = Project(**yaml_data) + metadata1 = ExtractedMetadata(version="4.5.6") + metadata2 = ExtractedMetadata( + summary="metadata summary", description="metadata description" + ) + metadata3 = ExtractedMetadata( + version="7.8.9", title="metadata title", grade="devel" + ) + metadata4 = ExtractedMetadata( + summary="extra summary", description="extra description" + ) + prj_vars = {"version": "", "grade": ""} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata1, metadata2, metadata3, metadata4], + assets_dir=new_dir, + prime_dir=new_dir, + ) + + assert project.version == expected["version"] + assert project.summary == expected["summary"] + assert project.description == expected["description"] + assert project.title == expected["title"] + assert project.grade == expected["grade"] + + +@pytest.mark.parametrize( + "project_entries,icon_exists,asset_exists,expected_icon", + [ + ({"icon": "icon.png"}, True, True, "icon.png"), # use project icon if defined + ( # use project icon if defined even if already in assets + {"icon": "icon.png"}, + True, + False, + "icon.png", + ), + ( # use metadata icon if not defined in project + {}, + True, + False, + "metadata_icon.png", + ), + ({}, False, False, None), # only use metadata icon if file exists + ({}, True, True, None), # don't use metadata if asset icon already exists + ], +) +def test_update_project_metadata_icon( + project_yaml_data, + project_entries, + icon_exists, + asset_exists, + expected_icon, + new_dir, +): + yaml_data = project_yaml_data( + {"version": "1.0", "adopt-info": "part", "parts": {}, **project_entries} + ) + project = Project(**yaml_data) + metadata = ExtractedMetadata(icon="metadata_icon.png") + + # create icon file + if icon_exists: + Path("metadata_icon.png").touch() + + # create icon file in assets dir + if asset_exists: + Path("assets/gui").mkdir(parents=True) + Path("assets/gui/icon.svg").touch() + + prj_vars = {"version": "", "grade": "stable"} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=new_dir / "assets", + prime_dir=new_dir, + ) + + assert project.icon == expected_icon + + +@pytest.mark.parametrize( + "project_entries,desktop_exists,asset_exists,expected_desktop", + [ + ( # use project desktop file if defined + { + "apps": { + "foo": { + "command": "foo", + "common-id": "test.id", + "desktop": "project/foo.desktop", + }, + }, + }, + True, + False, + "project/foo.desktop", + ), + ( # use project desktop if no common-id defined + { + "apps": { + "foo": { + "command": "foo", + "desktop": "project/foo.desktop", + }, + }, + }, + True, + False, + "project/foo.desktop", + ), + ( # don't read from metadata if common-id not defined + { + "apps": { + "foo": { + "command": "foo", + }, + }, + }, + True, + False, + None, + ), + ( # use metadata if no project definition and metadata icon exists + { + "apps": { + "foo": { + "command": "foo", + "common-id": "test.id", + }, + }, + }, + True, + False, + "metadata/foo.desktop", + ), + ( # only use metadata desktop file if it exists + { + "apps": { + "foo": { + "command": "foo", + "common-id": "test.id", + }, + }, + }, + False, + False, + None, + ), + ( # existing file has precedence over metadata + { + "apps": { + "foo": { + "command": "foo", + "common-id": "test.id", + }, + }, + }, + True, + True, + None, + ), + ], +) +def test_update_project_metadata_desktop( + project_yaml_data, + project_entries, + desktop_exists, + asset_exists, + expected_desktop, + new_dir, +): + yaml_data = project_yaml_data( + {"version": "1.0", "adopt-info": "part", "parts": {}, **project_entries} + ) + project = Project(**yaml_data) + metadata = ExtractedMetadata( + common_id="test.id", desktop_file_paths=["metadata/foo.desktop"] + ) + + # create desktop file + if desktop_exists: + Path("metadata").mkdir() + Path("metadata/foo.desktop").touch() + + # create desktop file in assets dir + if asset_exists: + Path("assets/gui").mkdir(parents=True) + Path("assets/gui/foo.desktop").touch() + + prj_vars = {"version": "", "grade": "stable"} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=new_dir / "assets", + prime_dir=new_dir, + ) + + assert project.apps is not None + assert project.apps["foo"].desktop == expected_desktop + + +def test_update_project_metadata_desktop_multiple(project_yaml_data, new_dir): + yaml_data = project_yaml_data( + { + "version": "1.0", + "adopt-info": "part", + "parts": {}, + "apps": { + "foo": { + "command": "foo", + "common-id": "test.id", + }, + }, + } + ) + project = Project(**yaml_data) + metadata = ExtractedMetadata( + common_id="test.id", + desktop_file_paths=["metadata/foo.desktop", "metadata/bar.desktop"], + ) + + # create desktop files + Path("metadata").mkdir() + Path("metadata/foo.desktop").touch() + Path("metadata/bar.desktop").touch() + + prj_vars = {"version": "", "grade": "stable"} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=new_dir / "assets", + prime_dir=new_dir, + ) + + assert project.apps is not None + assert project.apps["foo"].desktop == "metadata/foo.desktop" + + +def test_update_project_metadata_multiple_apps(project_yaml_data, new_dir): + yaml_data = project_yaml_data( + { + "version": "1.0", + "adopt-info": "part", + "parts": {}, + "apps": { + "foo": { + "command": "foo", + "common-id": "foo.id", + }, + "bar": { + "command": "bar", + "common-id": "bar.id", + }, + }, + } + ) + project = Project(**yaml_data) + metadata1 = ExtractedMetadata( + common_id="foo.id", + desktop_file_paths=["metadata/foo.desktop"], + ) + metadata2 = ExtractedMetadata( + common_id="bar.id", + desktop_file_paths=["metadata/bar.desktop"], + ) + + # create desktop files + Path("metadata").mkdir() + Path("metadata/foo.desktop").touch() + Path("metadata/bar.desktop").touch() + + prj_vars = {"version": "", "grade": "stable"} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata1, metadata2], + assets_dir=new_dir / "assets", + prime_dir=new_dir, + ) + + assert project.apps is not None + assert project.apps["foo"].desktop == "metadata/foo.desktop" + assert project.apps["bar"].desktop == "metadata/bar.desktop" + + +def test_update_project_metadata_desktop_no_apps(project_yaml_data, new_dir): + yaml_data = project_yaml_data( + { + "version": "1.0", + "adopt-info": "part", + "parts": {}, + } + ) + project = Project(**yaml_data) + metadata = ExtractedMetadata( + common_id="test.id", + desktop_file_paths=["metadata/foo.desktop", "metadata/bar.desktop"], + ) + + # create desktop file + Path("metadata").mkdir() + Path("metadata/foo.desktop").touch() + Path("metadata/bar.desktop").touch() + + prj_vars = {"version": "", "grade": "stable"} + + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=new_dir / "assets", + prime_dir=new_dir, + ) + + assert project.apps is None diff --git a/tests/unit/parts/test_validation.py b/tests/unit/parts/test_validation.py new file mode 100644 index 0000000000..28064f39bb --- /dev/null +++ b/tests/unit/parts/test_validation.py @@ -0,0 +1,66 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import copy + +import pydantic +import pytest + +from snapcraft.parts.validation import validate_part + + +def test_part_validation_data_type(): + with pytest.raises(TypeError) as raised: + validate_part("invalid data") # type: ignore + + assert str(raised.value) == "value must be a dictionary" + + +def test_part_validation_immutable(): + data = { + "plugin": "make", + "source": "foo", + "make-parameters": ["-C bar"], + } + data_copy = copy.deepcopy(data) + + validate_part(data) + + assert data == data_copy + + +def test_part_validation_extra(): + data = { + "plugin": "make", + "source": "foo", + "make-parameters": ["-C bar"], + "unexpected-extra": True, + } + + error = r"unexpected-extra\s+extra fields not permitted" + with pytest.raises(pydantic.ValidationError, match=error): + validate_part(data) + + +def test_part_validation_missing(): + data = { + "plugin": "make", + "make-parameters": ["-C bar"], + } + + error = r"source\s+field required" + with pytest.raises(pydantic.ValidationError, match=error): + validate_part(data) diff --git a/tests/unit/parts/test_yaml_utils.py b/tests/unit/parts/test_yaml_utils.py new file mode 100644 index 0000000000..930fb5d8a1 --- /dev/null +++ b/tests/unit/parts/test_yaml_utils.py @@ -0,0 +1,124 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import io +from textwrap import dedent + +import pytest + +from snapcraft import errors +from snapcraft.parts import yaml_utils + + +def test_yaml_load(): + assert ( + yaml_utils.load( + io.StringIO( + dedent( + """\ + base: core22 + entry: + sub-entry: + - list1 + - list2 + scalar: scalar-value + """ + ) + ) + ) + == { + "base": "core22", + "entry": { + "sub-entry": ["list1", "list2"], + }, + "scalar": "scalar-value", + } + ) + + +def test_yaml_load_duplicates_errors(): + with pytest.raises(errors.SnapcraftError) as raised: + yaml_utils.load( + io.StringIO( + dedent( + """\ + base: core22 + entry: value1 + entry: value2 + """ + ) + ) + ) + + assert str(raised.value) == dedent( + """\ + snapcraft.yaml parsing error: while constructing a mapping + found duplicate key 'entry' + in "", line 1, column 1""" + ) + + +def test_yaml_load_unhashable_errors(): + with pytest.raises(errors.SnapcraftError) as raised: + yaml_utils.load( + io.StringIO( + dedent( + """\ + base: core22 + entry: {{value}} + """ + ) + ) + ) + + assert str(raised.value) == dedent( + """\ + snapcraft.yaml parsing error: while constructing a mapping + in "", line 2, column 8 + found unhashable key + in "", line 2, column 9""" + ) + + +def test_yaml_load_not_core22_base(): + with pytest.raises(errors.LegacyFallback) as raised: + yaml_utils.load( + io.StringIO( + dedent( + """\ + base: core20 + """ + ) + ) + ) + + assert str(raised.value) == "base is not core22" + + +def test_yaml_load_no_base(): + with pytest.raises(errors.LegacyFallback) as raised: + yaml_utils.load( + io.StringIO( + dedent( + """\ + entry: foo + """ + ) + ) + ) + + assert str(raised.value) == "no base defined" diff --git a/tests/unit/project_loader/grammar/test_compound_statement.py b/tests/unit/project_loader/grammar/test_compound_statement.py deleted file mode 100644 index c9b9df8c94..0000000000 --- a/tests/unit/project_loader/grammar/test_compound_statement.py +++ /dev/null @@ -1,269 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import platform -import re - -import pytest - -import snapcraft -import snapcraft.internal.project_loader.grammar._compound as compound -import snapcraft.internal.project_loader.grammar._on as on -import snapcraft.internal.project_loader.grammar._to as to -from snapcraft.internal.project_loader import grammar - - -class TestCompoundStatementGrammar: - - scenarios = [ - ( - "on amd64", - { - "on_arch": "on amd64", - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "on i386", - { - "on_arch": "on amd64", - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [], - "host_arch": "i686", - "expected_packages": list(), - }, - ), - ( - "ignored else", - { - "on_arch": "on amd64", - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [["bar"]], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "used else", - { - "on_arch": "on amd64", - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [["bar"]], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "third else ignored", - { - "on_arch": "on amd64", - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [["bar"], ["baz"]], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "third else followed", - { - "on_arch": "on amd64", - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [[{"on armhf": ["bar"]}], ["baz"]], - "host_arch": "i686", - "expected_packages": ["baz"], - }, - ), - ( - "nested amd64", - { - "on_arch": "on amd64", - "to_arch": "to armhf", - "body": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "nested i386", - { - "on_arch": "on i386", - "to_arch": "to armhf", - "body": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}], - "else_bodies": [], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "nested body ignored else", - { - "on_arch": "on amd64", - "to_arch": "to armhf", - "body": [{"on amd64": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "nested body used else", - { - "on_arch": "on i386", - "to_arch": "to armhf", - "body": [{"on amd64": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "nested else ignored else", - { - "on_arch": "on armhf", - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [[{"on amd64": ["bar"]}, {"else": ["baz"]}]], - "host_arch": "x86_64", - "expected_packages": ["bar"], - }, - ), - ( - "nested else used else", - { - "on_arch": "on armhf", - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [[{"on amd64": ["bar"]}, {"else": ["baz"]}]], - "host_arch": "i686", - "expected_packages": ["baz"], - }, - ), - ( - "with hyphen", - { - "on_arch": "on other-arch", - "to_arch": "to yet-another-arch", - "body": ["foo"], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": list(), - }, - ), - ( - "multiple selectors", - { - "on_arch": "on amd64,i386", - "to_arch": "to armhf,arm64", - "body": ["foo"], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": list(), - }, - ), - ] - - def test( - self, - monkeypatch, - on_arch, - to_arch, - body, - else_bodies, - host_arch, - expected_packages, - ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(target_deb_arch="armhf"), lambda x: True - ) - statements = [ - on.OnStatement(on=on_arch, body=None, processor=processor), - to.ToStatement(to=to_arch, body=None, processor=processor), - ] - statement = compound.CompoundStatement( - statements=statements, body=body, processor=processor - ) - - for else_body in else_bodies: - statement.add_else(else_body) - - assert statement.process() == expected_packages - - -class TestCompoundStatementInvalidGrammar: - - scenarios = [ - ( - "spaces in on selectors", - { - "on_arch": "on amd64, ubuntu", - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [], - "expected_exception": grammar.errors.OnStatementSyntaxError, - "expected_message": ".*not a valid 'on' clause.*spaces are not allowed in the " - "selectors.*", - }, - ), - ( - "spaces in to selectors", - { - "on_arch": "on amd64,ubuntu", - "to_arch": "to i386, armhf", - "body": ["foo"], - "else_bodies": [], - "expected_exception": grammar.errors.ToStatementSyntaxError, - "expected_message": ".*not a valid 'to' clause.*spaces are not allowed in the " - "selectors.*", - }, - ), - ] - - def test( - self, on_arch, to_arch, body, else_bodies, expected_exception, expected_message - ): - with pytest.raises(expected_exception) as error: - processor = grammar.GrammarProcessor( - None, - snapcraft.ProjectOptions(target_deb_arch="armhf"), - lambda x: "invalid" not in x, - ) - statements = [ - on.OnStatement(on=on_arch, body=None, processor=processor), - to.ToStatement(to=to_arch, body=None, processor=processor), - ] - statement = compound.CompoundStatement( - statements=statements, body=body, processor=processor - ) - - for else_body in else_bodies: - statement.add_else(else_body) - - statement.process() - - assert re.match(expected_message, str(error.value)) diff --git a/tests/unit/project_loader/grammar/test_on_statement.py b/tests/unit/project_loader/grammar/test_on_statement.py deleted file mode 100644 index 821af90c07..0000000000 --- a/tests/unit/project_loader/grammar/test_on_statement.py +++ /dev/null @@ -1,264 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import doctest -import platform -import re - -import pytest - -import snapcraft -import snapcraft.internal.project_loader.grammar._on as on -from snapcraft.internal.project_loader import grammar - - -def load_tests(loader, tests, ignore): - tests.addTests(doctest.DocTestSuite(on)) - return tests - - -class TestOnStatementGrammar: - - scenarios = [ - ( - "on amd64", - { - "on_arch": "on amd64", - "body": ["foo"], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "on i386", - { - "on_arch": "on amd64", - "body": ["foo"], - "else_bodies": [], - "host_arch": "i686", - "expected_packages": list(), - }, - ), - ( - "ignored else", - { - "on_arch": "on amd64", - "body": ["foo"], - "else_bodies": [["bar"]], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "used else", - { - "on_arch": "on amd64", - "body": ["foo"], - "else_bodies": [["bar"]], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "third else ignored", - { - "on_arch": "on amd64", - "body": ["foo"], - "else_bodies": [["bar"], ["baz"]], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "third else followed", - { - "on_arch": "on amd64", - "body": ["foo"], - "else_bodies": [[{"on armhf": ["bar"]}], ["baz"]], - "host_arch": "i686", - "expected_packages": ["baz"], - }, - ), - ( - "nested amd64", - { - "on_arch": "on amd64", - "body": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "nested i386", - { - "on_arch": "on i386", - "body": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}], - "else_bodies": [], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "nested body ignored else", - { - "on_arch": "on amd64", - "body": [{"on amd64": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "host_arch": "x86_64", - "expected_packages": ["foo"], - }, - ), - ( - "nested body used else", - { - "on_arch": "on i386", - "body": [{"on amd64": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "host_arch": "i686", - "expected_packages": ["bar"], - }, - ), - ( - "nested else ignored else", - { - "on_arch": "on armhf", - "body": ["foo"], - "else_bodies": [[{"on amd64": ["bar"]}, {"else": ["baz"]}]], - "host_arch": "x86_64", - "expected_packages": ["bar"], - }, - ), - ( - "nested else used else", - { - "on_arch": "on armhf", - "body": ["foo"], - "else_bodies": [[{"on amd64": ["bar"]}, {"else": ["baz"]}]], - "host_arch": "i686", - "expected_packages": ["baz"], - }, - ), - ] - - def test( - self, monkeypatch, on_arch, body, else_bodies, host_arch, expected_packages - ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(), lambda x: True - ) - statement = on.OnStatement(on=on_arch, body=body, processor=processor) - - for else_body in else_bodies: - statement.add_else(else_body) - - assert statement.process() == expected_packages - - -class TestOnStatementInvalidGrammar: - - scenarios = [ - ( - "spaces in selectors", - { - "on_arch": "on amd64, ubuntu", - "body": ["foo"], - "else_bodies": [], - "expected_exception": ".*not a valid 'on' clause.*spaces are not allowed in the " - "selectors.*", - }, - ), - ( - "beginning with comma", - { - "on_arch": "on ,amd64", - "body": ["foo"], - "else_bodies": [], - "expected_exception": ".*not a valid 'on' clause", - }, - ), - ( - "ending with comma", - { - "on_arch": "on amd64,", - "body": ["foo"], - "else_bodies": [], - "expected_exception": ".*not a valid 'on' clause", - }, - ), - ( - "multiple commas", - { - "on_arch": "on amd64,,ubuntu", - "body": ["foo"], - "else_bodies": [], - "expected_exception": ".*not a valid 'on' clause", - }, - ), - ( - "invalid selector format", - { - "on_arch": "on", - "body": ["foo"], - "else_bodies": [], - "expected_exception": ".*not a valid 'on' clause.*selectors are missing", - }, - ), - ( - "not even close", - { - "on_arch": "im-invalid", - "body": ["foo"], - "else_bodies": [], - "expected_exception": ".*not a valid 'on' clause", - }, - ), - ] - - def test(self, on_arch, body, else_bodies, expected_exception): - with pytest.raises(grammar.errors.OnStatementSyntaxError) as error: - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(), lambda x: "invalid" not in x - ) - statement = on.OnStatement(on=on_arch, body=body, processor=processor) - - for else_body in else_bodies: - statement.add_else(else_body) - - statement.process() - - assert re.match(expected_exception, str(error.value)) - - -def test_else_fail(monkeypatch): - monkeypatch.setattr(platform, "machine", lambda: "x86_64") - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(), lambda x: True - ) - statement = on.OnStatement(on="on i386", body=["foo"], processor=processor) - - statement.add_else(None) - - with pytest.raises(grammar.errors.UnsatisfiedStatementError) as error: - statement.process() - - assert str(error.value) == "Unable to satisfy 'on i386', failure forced" diff --git a/tests/unit/project_loader/grammar/test_processor.py b/tests/unit/project_loader/grammar/test_processor.py deleted file mode 100644 index 0a083c0649..0000000000 --- a/tests/unit/project_loader/grammar/test_processor.py +++ /dev/null @@ -1,436 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import platform -import re - -import pytest - -import snapcraft -import snapcraft.internal.project_loader.grammar._to as _to -from snapcraft.internal.project_loader import grammar - - -@pytest.mark.parametrize( - "entry", - [ - [{"on amd64,i386": ["foo"]}, {"on amd64,i386": ["bar"]}], - [{"on amd64,i386": ["foo"]}, {"on i386,amd64": ["bar"]}], - ], -) -def test_duplicates(entry): - """Test that multiple identical selector sets is an error.""" - - processor = grammar.GrammarProcessor( - entry, snapcraft.ProjectOptions(), lambda x: True - ) - with pytest.raises(grammar.errors.GrammarSyntaxError) as error: - processor.process() - - expected = ( - "Invalid grammar syntax: found duplicate 'on amd64,i386' " - "statements. These should be merged." - ) - assert expected in str(error.value) - - -class TestBasicGrammar: - - scenarios = [ - ( - "unconditional", - { - "grammar_entry": ["foo", "bar"], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo", "bar"], - }, - ), - ( - "unconditional dict", - { - "grammar_entry": [{"foo": "bar"}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": [{"foo": "bar"}], - }, - ), - ( - "unconditional multi-dict", - { - "grammar_entry": [{"foo": "bar"}, {"foo2": "bar2"}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": [{"foo": "bar"}, {"foo2": "bar2"}], - }, - ), - ( - "mixed including", - { - "grammar_entry": ["foo", {"on i386": ["bar"]}], - "host_arch": "i686", - "target_arch": "i386", - "expected_results": ["foo", "bar"], - }, - ), - ( - "mixed excluding", - { - "grammar_entry": ["foo", {"on i386": ["bar"]}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo"], - }, - ), - ( - "on amd64", - { - "grammar_entry": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo"], - }, - ), - ( - "on i386", - { - "grammar_entry": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}], - "host_arch": "i686", - "target_arch": "i386", - "expected_results": ["bar"], - }, - ), - ( - "ignored else", - { - "grammar_entry": [{"on amd64": ["foo"]}, {"else": ["bar"]}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo"], - }, - ), - ( - "used else", - { - "grammar_entry": [{"on amd64": ["foo"]}, {"else": ["bar"]}], - "host_arch": "i686", - "target_arch": "i386", - "expected_results": ["bar"], - }, - ), - ( - "nested amd64", - { - "grammar_entry": [ - {"on amd64": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}]} - ], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo"], - }, - ), - ( - "nested amd64 dict", - { - "grammar_entry": [ - {"on amd64": [{"on amd64": [{"foo": "bar"}]}, {"on i386": ["bar"]}]} - ], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": [{"foo": "bar"}], - }, - ), - ( - "nested i386", - { - "grammar_entry": [ - {"on i386": [{"on amd64": ["foo"]}, {"on i386": ["bar"]}]} - ], - "host_arch": "i686", - "target_arch": "i386", - "expected_results": ["bar"], - }, - ), - ( - "nested ignored else", - { - "grammar_entry": [ - {"on amd64": [{"on amd64": ["foo"]}, {"else": ["bar"]}]} - ], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo"], - }, - ), - ( - "nested used else", - { - "grammar_entry": [ - {"on i386": [{"on amd64": ["foo"]}, {"else": ["bar"]}]} - ], - "host_arch": "i686", - "target_arch": "amd64", - "expected_results": ["bar"], - }, - ), - ( - "try", - { - "grammar_entry": [{"try": ["valid"]}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["valid"], - }, - ), - ( - "try else", - { - "grammar_entry": [{"try": ["invalid"]}, {"else": ["valid"]}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["valid"], - }, - ), - ( - "nested try", - { - "grammar_entry": [{"on amd64": [{"try": ["foo"]}, {"else": ["bar"]}]}], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": ["foo"], - }, - ), - ( - "nested try else", - { - "grammar_entry": [ - {"on i386": [{"try": ["invalid"]}, {"else": ["bar"]}]} - ], - "host_arch": "i686", - "target_arch": "i686", - "expected_results": ["bar"], - }, - ), - ( - "optional", - { - "grammar_entry": ["foo", {"try": ["invalid"]}], - "host_arch": "i686", - "target_arch": "i386", - "expected_results": ["foo"], - }, - ), - ( - "multi", - { - "grammar_entry": [ - "foo", - {"on amd64": ["foo2"]}, - {"on amd64 to arm64": ["foo3"]}, - ], - "host_arch": "x86_64", - "target_arch": "i386", - "expected_results": ["foo", "foo2"], - }, - ), - ( - "multi-ordering", - { - "grammar_entry": [ - "foo", - {"on amd64": ["on-foo"]}, - "after-on", - {"on amd64 to i386": ["on-to-foo"]}, - {"on amd64 to arm64": ["no-show"]}, - "n-1", - "n", - ], - "host_arch": "x86_64", - "target_arch": "i386", - "expected_results": [ - "foo", - "on-foo", - "after-on", - "on-to-foo", - "n-1", - "n", - ], - }, - ), - ( - "complex nested dicts", - { - "grammar_entry": [ - {"yes1": "yes1"}, - { - "on amd64": [ - {"yes2": "yes2"}, - {"on amd64": [{"yes3": "yes3"}]}, - {"yes4": "yes4"}, - {"on i386": [{"no1": "no1"}]}, - {"else": [{"yes5": "yes5"}]}, - {"yes6": "yes6"}, - ], - }, - {"else": [{"no2": "no2"}]}, - {"yes7": "yes7"}, - {"on i386": [{"no3": "no3"}]}, - {"else": [{"yes8": "yes8"}]}, - {"yes9": "yes9"}, - ], - "host_arch": "x86_64", - "target_arch": "amd64", - "expected_results": [ - {"yes1": "yes1"}, - {"yes2": "yes2"}, - {"yes3": "yes3"}, - {"yes4": "yes4"}, - {"yes5": "yes5"}, - {"yes6": "yes6"}, - {"yes7": "yes7"}, - {"yes8": "yes8"}, - {"yes9": "yes9"}, - ], - }, - ), - ] - - def test_basic_grammar( - self, monkeypatch, grammar_entry, host_arch, target_arch, expected_results - ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - - project = snapcraft.ProjectOptions(target_deb_arch=target_arch) - - processor = grammar.GrammarProcessor( - grammar_entry, project, lambda x: "invalid" not in x - ) - assert processor.process() == expected_results - - -class TestTransformerGrammar: - - scenarios = [ - ( - "unconditional", - { - "grammar_entry": ["foo", "bar"], - "host_arch": "x86_64", - "expected_results": ["foo", "bar"], - }, - ), - ( - "mixed including", - { - "grammar_entry": ["foo", {"on i386": ["bar"]}], - "host_arch": "i686", - "expected_results": ["foo", "bar"], - }, - ), - ( - "mixed excluding", - { - "grammar_entry": ["foo", {"on i386": ["bar"]}], - "host_arch": "x86_64", - "expected_results": ["foo"], - }, - ), - ( - "to", - { - "grammar_entry": [{"to i386": ["foo"]}], - "host_arch": "x86_64", - "expected_results": ["foo:i386"], - }, - ), - ( - "transform applies to nested", - { - "grammar_entry": [{"to i386": [{"on amd64": ["foo"]}]}], - "host_arch": "x86_64", - "expected_results": ["foo:i386"], - }, - ), - ( - "not to", - { - "grammar_entry": [{"to amd64": ["foo"]}, {"else": ["bar"]}], - "host_arch": "x86_64", - "expected_results": ["bar"], - }, - ), - ] - - def test_grammar_with_transformer( - self, monkeypatch, grammar_entry, host_arch, expected_results - ): - monkeypatch.setattr(platform, "machine", lambda: host_arch) - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - - # Transform all 'to' statements to include arch - def _transformer(call_stack, package_name, project_options): - if any(isinstance(s, _to.ToStatement) for s in call_stack): - if ":" not in package_name: - package_name += ":{}".format(project_options.deb_arch) - - return package_name - - processor = grammar.GrammarProcessor( - grammar_entry, - snapcraft.ProjectOptions(target_deb_arch="i386"), - lambda x: True, - transformer=_transformer, - ) - - assert processor.process() == expected_results - - -class TestInvalidGrammar: - - scenarios = [ - ( - "unmatched else", - { - "grammar_entry": [{"else": ["foo"]}], - "expected_exception": ".*'else' doesn't seem to correspond.*", - }, - ), - ( - "unmatched else fail", - { - "grammar_entry": ["else fail"], - "expected_exception": ".*'else' doesn't seem to correspond.*", - }, - ), - ( - "unexpected type", - { - "grammar_entry": [5], - "expected_exception": ".*expected grammar section.*but got.*", - }, - ), - ] - - def test_invalid_grammar(self, grammar_entry, expected_exception): - processor = grammar.GrammarProcessor( - grammar_entry, snapcraft.ProjectOptions(), lambda x: True - ) - - with pytest.raises(grammar.errors.GrammarSyntaxError) as error: - processor.process() - - assert re.match(expected_exception, str(error.value)) diff --git a/tests/unit/project_loader/grammar/test_to_statement.py b/tests/unit/project_loader/grammar/test_to_statement.py deleted file mode 100644 index a66a76832c..0000000000 --- a/tests/unit/project_loader/grammar/test_to_statement.py +++ /dev/null @@ -1,301 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import doctest -import platform -import re - -import pytest - -import snapcraft -import snapcraft.internal.project_loader.grammar._to as to -from snapcraft.internal.project_loader import grammar - - -def load_tests(loader, tests, ignore): - tests.addTests(doctest.DocTestSuite(to)) - return tests - - -class TestToStatementGrammar: - - scenarios = [ - ( - "no target arch", - { - "to_arch": "to amd64", - "body": ["foo"], - "else_bodies": [], - "target_arch": None, - "expected_packages": ["foo"], - }, - ), - ( - "amd64 to armhf", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_packages": ["foo"], - }, - ), - ( - "amd64 to armhf, arch specified", - { - "to_arch": "to armhf", - "body": ["foo:amd64"], - "else_bodies": [], - "target_arch": "armhf", - "expected_packages": ["foo:amd64"], - }, - ), - ( - "amd64 to i386", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [], - "target_arch": "i386", - "expected_packages": list(), - }, - ), - ( - "ignored else", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [["bar"]], - "target_arch": "armhf", - "expected_packages": ["foo"], - }, - ), - ( - "used else", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [["bar"]], - "target_arch": "i386", - "expected_packages": ["bar"], - }, - ), - ( - "used else, arch specified", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [["bar:amd64"]], - "target_arch": "i386", - "expected_packages": ["bar:amd64"], - }, - ), - ( - "third else ignored", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [["bar"], ["baz"]], - "target_arch": "i386", - "expected_packages": ["bar"], - }, - ), - ( - "third else followed", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [[{"to armhf": ["bar"]}], ["baz"]], - "target_arch": "i386", - "expected_packages": ["baz"], - }, - ), - ( - "nested armhf", - { - "to_arch": "to armhf", - "body": [{"to armhf": ["foo"]}, {"to i386": ["bar"]}], - "else_bodies": [], - "target_arch": "armhf", - "expected_packages": ["foo"], - }, - ), - ( - "nested i386", - { - "to_arch": "to i386", - "body": [{"to armhf": ["foo"]}, {"to i386": ["bar"]}], - "else_bodies": [], - "target_arch": "i386", - "expected_packages": ["bar"], - }, - ), - ( - "nested body ignored else", - { - "to_arch": "to armhf", - "body": [{"to armhf": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "target_arch": "armhf", - "expected_packages": ["foo"], - }, - ), - ( - "nested body used else", - { - "to_arch": "to i386", - "body": [{"to armhf": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "target_arch": "i386", - "expected_packages": ["bar"], - }, - ), - ( - "nested else ignored else", - { - "to_arch": "to i386", - "body": ["foo"], - "else_bodies": [[{"to armhf": ["bar"]}, {"else": ["baz"]}]], - "target_arch": "armhf", - "expected_packages": ["bar"], - }, - ), - ( - "nested else used else", - { - "to_arch": "to armhf", - "body": ["foo"], - "else_bodies": [[{"to armhf": ["bar"]}, {"else": ["baz"]}]], - "target_arch": "i386", - "expected_packages": ["baz"], - }, - ), - ] - - def test( - self, monkeypatch, to_arch, body, else_bodies, target_arch, expected_packages - ): - monkeypatch.setattr(platform, "machine", lambda: "x86_64") - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(target_deb_arch=target_arch), lambda x: True - ) - statement = to.ToStatement(to=to_arch, body=body, processor=processor) - - for else_body in else_bodies: - statement.add_else(else_body) - - assert statement.process() == expected_packages - - -class TestToStatementInvalidGrammar: - - scenarios = [ - ( - "spaces in selectors", - { - "to_arch": "to armhf, ubuntu", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_exception": ".*not a valid 'to' clause.*spaces are not allowed in the " - "selectors.*", - }, - ), - ( - "beginning with comma", - { - "to_arch": "to ,armhf", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_exception": ".*not a valid 'to' clause", - }, - ), - ( - "ending with comma", - { - "to_arch": "to armhf,", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_exception": ".*not a valid 'to' clause", - }, - ), - ( - "multiple commas", - { - "to_arch": "to armhf,,ubuntu", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_exception": ".*not a valid 'to' clause", - }, - ), - ( - "invalid selector format", - { - "to_arch": "to_arch", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_exception": ".*not a valid 'to' clause.*selectors are missing", - }, - ), - ( - "not even close", - { - "to_arch": "im-invalid", - "body": ["foo"], - "else_bodies": [], - "target_arch": "armhf", - "expected_exception": ".*not a valid 'to' clause", - }, - ), - ] - - def test(self, to_arch, body, else_bodies, target_arch, expected_exception): - with pytest.raises(grammar.errors.ToStatementSyntaxError) as error: - processor = grammar.GrammarProcessor( - None, - snapcraft.ProjectOptions(target_deb_arch=target_arch), - lambda x: True, - ) - statement = to.ToStatement(to=to_arch, body=body, processor=processor) - - for else_body in else_bodies: - statement.add_else(else_body) - - statement.process() - - assert re.match(expected_exception, str(error.value)) - - -def test_else_fail(monkeypatch): - monkeypatch.setattr(platform, "machine", lambda: "x86_64") - monkeypatch.setattr(platform, "architecture", lambda: ("64bit", "ELF")) - - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(target_deb_arch="i386"), lambda x: True - ) - statement = to.ToStatement(to="to armhf", body=["foo"], processor=processor) - - statement.add_else(None) - - with pytest.raises(grammar.errors.UnsatisfiedStatementError) as error: - statement.process() - - assert str(error.value) == "Unable to satisfy 'to armhf', failure forced" diff --git a/tests/unit/project_loader/grammar/test_try_statement.py b/tests/unit/project_loader/grammar/test_try_statement.py deleted file mode 100644 index 32010fe0c9..0000000000 --- a/tests/unit/project_loader/grammar/test_try_statement.py +++ /dev/null @@ -1,135 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017, 2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import doctest - -import pytest - -import snapcraft -import snapcraft.internal.project_loader.grammar._try as _try -from snapcraft.internal.project_loader import grammar - - -def load_tests(loader, tests, ignore): - tests.addTests(doctest.DocTestSuite(_try)) - return tests - - -class TestTryStatementGrammar: - - scenarios = [ - ( - "followed body", - { - "body": ["foo", "bar"], - "else_bodies": [], - "expected_packages": ["foo", "bar"], - }, - ), - ( - "followed else", - { - "body": ["invalid"], - "else_bodies": [["valid"]], - "expected_packages": ["valid"], - }, - ), - ( - "optional without else", - {"body": ["invalid"], "else_bodies": [], "expected_packages": list()}, - ), - ( - "followed chained else", - { - "body": ["invalid1"], - "else_bodies": [["invalid2"], ["finally-valid"]], - "expected_packages": ["finally-valid"], - }, - ), - ( - "nested body followed body", - { - "body": [{"try": ["foo"]}, {"else": ["bar"]}], - "else_bodies": [], - "expected_packages": ["foo"], - }, - ), - ( - "nested body followed else", - { - "body": [{"try": ["invalid"]}, {"else": ["bar"]}], - "else_bodies": [], - "expected_packages": ["bar"], - }, - ), - ( - "nested else followed body", - { - "body": ["invalid"], - "else_bodies": [[{"try": ["foo"]}, {"else": ["bar"]}]], - "expected_packages": ["foo"], - }, - ), - ( - "nested else followed else", - { - "body": ["invalid"], - "else_bodies": [[{"try": ["invalid"]}, {"else": ["bar"]}]], - "expected_packages": ["bar"], - }, - ), - ( - "multiple elses", - { - "body": ["invalid1"], - "else_bodies": [["invalid2"], ["valid"]], - "expected_packages": ["valid"], - }, - ), - ( - "multiple elses all invalid", - { - "body": ["invalid1"], - "else_bodies": [["invalid2"], ["invalid3"]], - "expected_packages": ["invalid3"], - }, - ), - ] - - def test_try_statement_grammar(self, body, else_bodies, expected_packages): - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(), lambda x: "invalid" not in x - ) - statement = _try.TryStatement(body=body, processor=processor) - - for else_body in else_bodies: - statement.add_else(else_body) - - assert statement.process() == expected_packages - - -def test_else_fail(): - processor = grammar.GrammarProcessor( - None, snapcraft.ProjectOptions(), lambda x: "invalid" not in x - ) - statement = _try.TryStatement(body=["invalid"], processor=processor) - - statement.add_else(None) - - with pytest.raises(grammar.errors.UnsatisfiedStatementError) as error: - statement.process() - - assert "Unable to satisfy 'try', failure forced" in str(error.value) diff --git a/tests/unit/repo/__init__.py b/tests/unit/repo/__init__.py index 5148651223..e69de29bb2 100644 --- a/tests/unit/repo/__init__.py +++ b/tests/unit/repo/__init__.py @@ -1,32 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2017-2018 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import logging -import tempfile - -import fixtures - -from tests import unit - - -class RepoBaseTestCase(unit.TestCase): - def setUp(self): - super().setUp() - fake_logger = fixtures.FakeLogger(level=logging.ERROR) - self.useFixture(fake_logger) - tempdirObj = tempfile.TemporaryDirectory() - self.addCleanup(tempdirObj.cleanup) - self.tempdir = tempdirObj.name diff --git a/tests/unit/repo/test_apt_key_manager.py b/tests/unit/repo/test_apt_key_manager.py index 26b610beff..4f3929b3e2 100644 --- a/tests/unit/repo/test_apt_key_manager.py +++ b/tests/unit/repo/test_apt_key_manager.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2020 Canonical Ltd +# Copyright 2020-2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -22,50 +22,45 @@ import gnupg import pytest -from snapcraft.internal.meta.package_repository import ( +from snapcraft.repo import apt_ppa, errors +from snapcraft.repo.apt_key_manager import AptKeyManager +from snapcraft.repo.package_repository import ( PackageRepositoryApt, - PackageRepositoryAptPpa, + PackageRepositoryAptPPA, ) -from snapcraft.internal.repo import apt_ppa, errors -from snapcraft.internal.repo.apt_key_manager import AptKeyManager @pytest.fixture(autouse=True) -def mock_environ_copy(): - with mock.patch("os.environ.copy") as m: - yield m +def mock_environ_copy(mocker): + yield mocker.patch("os.environ.copy") @pytest.fixture(autouse=True) -def mock_gnupg(tmp_path, autouse=True): - with mock.patch("gnupg.GPG", spec=gnupg.GPG) as m: - m.return_value.import_keys.return_value.fingerprints = [ - "FAKE-KEY-ID-FROM-GNUPG" - ] - yield m +def mock_gnupg(tmp_path, mocker): + m = mocker.patch("gnupg.GPG", spec=gnupg.GPG) + m.return_value.import_keys.return_value.fingerprints = ["FAKE-KEY-ID-FROM-GNUPG"] + yield m @pytest.fixture(autouse=True) -def mock_run(): - with mock.patch("subprocess.run", spec=subprocess.run) as m: - yield m +def mock_run(mocker): + yield mocker.patch("subprocess.run", spec=subprocess.run) @pytest.fixture(autouse=True) -def mock_apt_ppa_get_signing_key(): - with mock.patch( - "snapcraft.internal.repo.apt_ppa.get_launchpad_ppa_key_id", +def mock_apt_ppa_get_signing_key(mocker): + yield mocker.patch( + "snapcraft.repo.apt_ppa.get_launchpad_ppa_key_id", spec=apt_ppa.get_launchpad_ppa_key_id, return_value="FAKE-PPA-SIGNING-KEY", - ) as m: - yield m + ) @pytest.fixture def key_assets(tmp_path): - key_assets = tmp_path / "key-assets" - key_assets.mkdir(parents=True) - yield key_assets + assets = tmp_path / "key-assets" + assets.mkdir(parents=True) + yield assets @pytest.fixture @@ -138,7 +133,7 @@ def test_is_key_installed( assert is_installed is expected assert mock_run.mock_calls == [ call( - ["sudo", "apt-key", "export", "foo"], + ["apt-key", "export", "foo"], check=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, @@ -169,7 +164,7 @@ def test_install_key( assert mock_run.mock_calls == [ call( - ["sudo", "apt-key", "--keyring", str(gpg_keyring), "add", "-"], + ["apt-key", "--keyring", str(gpg_keyring), "add", "-"], check=True, env={"LANG": "C.UTF-8"}, input=b"some-fake-key", @@ -184,11 +179,10 @@ def test_install_key_with_apt_key_failure(apt_gpg, mock_run): cmd=["foo"], returncode=1, output=b"some error" ) - with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + with pytest.raises(errors.AptGPGKeyInstallError) as raised: apt_gpg.install_key(key="FAKEKEY") - assert exc_info.value._output == "some error" - assert exc_info.value._key == "FAKEKEY" + assert str(raised.value) == "Failed to install GPG key: some error" def test_install_key_from_keyserver(apt_gpg, gpg_keyring, mock_run): @@ -197,7 +191,6 @@ def test_install_key_from_keyserver(apt_gpg, gpg_keyring, mock_run): assert mock_run.mock_calls == [ call( [ - "sudo", "apt-key", "--keyring", str(gpg_keyring), @@ -222,26 +215,25 @@ def test_install_key_from_keyserver_with_apt_key_failure( cmd=["apt-key"], returncode=1, output=b"some error" ) - with pytest.raises(errors.AptGPGKeyInstallError) as exc_info: + with pytest.raises(errors.AptGPGKeyInstallError) as raised: apt_gpg.install_key_from_keyserver( key_id="fake-key-id", key_server="fake-server" ) - assert exc_info.value._output == "some error" - assert exc_info.value._key_id == "fake-key-id" + assert str(raised.value) == "Failed to install GPG key: some error" -@mock.patch("snapcraft.internal.repo.apt_key_manager.AptKeyManager.is_key_installed") @pytest.mark.parametrize( "is_installed", [True, False], ) def test_install_package_repository_key_already_installed( - mock_is_key_installed, - is_installed, - apt_gpg, + is_installed, apt_gpg, mocker ): - mock_is_key_installed.return_value = is_installed + mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=is_installed, + ) package_repo = PackageRepositoryApt( components=["main", "multiverse"], key_id="8" * 40, @@ -255,17 +247,15 @@ def test_install_package_repository_key_already_installed( assert updated is not is_installed -@mock.patch( - "snapcraft.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", - return_value=False, -) -@mock.patch("snapcraft.internal.repo.apt_key_manager.AptKeyManager.install_key") -def test_install_package_repository_key_from_asset( - mock_install_key, - mock_is_key_installed, - apt_gpg, - key_assets, -): +def test_install_package_repository_key_from_asset(apt_gpg, key_assets, mocker): + mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, + ) + mock_install_key = mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.install_key" + ) + key_id = "123456789012345678901234567890123456AABB" expected_key_path = key_assets / "3456AABB.asc" expected_key_path.write_text("key-data") @@ -283,18 +273,15 @@ def test_install_package_repository_key_from_asset( assert mock_install_key.mock_calls == [call(key="key-data")] -@mock.patch( - "snapcraft.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", - return_value=False, -) -@mock.patch( - "snapcraft.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" -) -def test_install_package_repository_key_apt_from_keyserver( - mock_install_key_from_keyserver, - mock_is_key_installed, - apt_gpg, -): +def test_install_package_repository_key_apt_from_keyserver(apt_gpg, mocker): + mock_install_key_from_keyserver = mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" + ) + mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, + ) + key_id = "8" * 40 package_repo = PackageRepositoryApt( @@ -313,22 +300,16 @@ def test_install_package_repository_key_apt_from_keyserver( ] -@mock.patch( - "snapcraft.internal.repo.apt_key_manager.AptKeyManager.is_key_installed", - return_value=False, -) -@mock.patch( - "snapcraft.internal.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" -) -def test_install_package_repository_key_ppa_from_keyserver( - mock_install_key_from_keyserver, - mock_is_key_installed, - apt_gpg, -): - package_repo = PackageRepositoryAptPpa( - ppa="test/ppa", +def test_install_package_repository_key_ppa_from_keyserver(apt_gpg, mocker): + mock_install_key_from_keyserver = mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.install_key_from_keyserver" + ) + mocker.patch( + "snapcraft.repo.apt_key_manager.AptKeyManager.is_key_installed", + return_value=False, ) + package_repo = PackageRepositoryAptPPA(ppa="test/ppa") updated = apt_gpg.install_package_repository_key(package_repo=package_repo) assert updated is True diff --git a/tests/unit/repo/test_apt_ppa.py b/tests/unit/repo/test_apt_ppa.py index 1acaf19b0d..e65d23f147 100644 --- a/tests/unit/repo/test_apt_ppa.py +++ b/tests/unit/repo/test_apt_ppa.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2020 Canonical Ltd +# Copyright 2020-2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -15,25 +15,23 @@ # along with this program. If not, see . -from unittest import mock from unittest.mock import call import launchpadlib import pytest -from snapcraft.internal.repo import apt_ppa, errors +from snapcraft.repo import apt_ppa, errors -@pytest.fixture -def mock_launchpad(autouse=True): - with mock.patch( - "snapcraft.internal.repo.apt_ppa.Launchpad", - spec=launchpadlib.launchpad.Launchpad, - ) as m: - m.login_anonymously.return_value.load.return_value.signing_key_fingerprint = ( - "FAKE-PPA-SIGNING-KEY" - ) - yield m +@pytest.fixture(autouse=True) +def mock_launchpad(mocker): + m = mocker.patch( + "snapcraft.repo.apt_ppa.Launchpad", spec=launchpadlib.launchpad.Launchpad + ) + m.login_anonymously.return_value.load.return_value.signing_key_fingerprint = ( + "FAKE-PPA-SIGNING-KEY" + ) + yield m def test_split_ppa_parts(): @@ -44,10 +42,12 @@ def test_split_ppa_parts(): def test_split_ppa_parts_invalid(): - with pytest.raises(errors.AptPPAInstallError) as exc_info: + with pytest.raises(errors.AptPPAInstallError) as raised: apt_ppa.split_ppa_parts(ppa="ppa-missing-slash") - assert exc_info.value._ppa == "ppa-missing-slash" + assert str(raised.value) == ( + "Failed to install PPA 'ppa-missing-slash': invalid PPA format" + ) def test_get_launchpad_ppa_key_id( diff --git a/tests/unit/repo/test_apt_sources_manager.py b/tests/unit/repo/test_apt_sources_manager.py index 200e9afcd4..19d95fcf0f 100644 --- a/tests/unit/repo/test_apt_sources_manager.py +++ b/tests/unit/repo/test_apt_sources_manager.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2021 Canonical Ltd +# Copyright 2021-2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -15,69 +15,50 @@ # along with this program. If not, see . -import pathlib -import subprocess from textwrap import dedent -from unittest import mock -from unittest.mock import call import pytest -from snapcraft.internal.meta.package_repository import ( +from snapcraft.repo import apt_ppa, apt_sources_manager, errors +from snapcraft.repo.package_repository import ( PackageRepositoryApt, - PackageRepositoryAptPpa, + PackageRepositoryAptPPA, ) -from snapcraft.internal.repo import apt_ppa, apt_sources_manager, errors @pytest.fixture(autouse=True) -def mock_apt_ppa_get_signing_key(): - with mock.patch( - "snapcraft.internal.repo.apt_ppa.get_launchpad_ppa_key_id", +def mock_apt_ppa_get_signing_key(mocker): + yield mocker.patch( + "snapcraft.repo.apt_ppa.get_launchpad_ppa_key_id", spec=apt_ppa.get_launchpad_ppa_key_id, return_value="FAKE-PPA-SIGNING-KEY", - ) as m: - yield m - - -@pytest.fixture(autouse=True) -def mock_environ_copy(): - with mock.patch("os.environ.copy") as m: - yield m + ) @pytest.fixture(autouse=True) -def mock_host_arch(): - with mock.patch("snapcraft.internal.repo.apt_sources_manager.ProjectOptions") as m: - m.return_value.deb_arch = "FAKE-HOST-ARCH" - yield m +def mock_environ_copy(mocker): + yield mocker.patch("os.environ.copy") @pytest.fixture(autouse=True) -def mock_run(): - with mock.patch("subprocess.run") as m: - yield m +def mock_host_arch(mocker): + m = mocker.patch("snapcraft.utils.get_host_architecture") + m.return_value = "FAKE-HOST-ARCH" + yield m -@pytest.fixture() -def mock_sudo_write(): - def write_file(*, dst_path: pathlib.Path, content: bytes) -> None: - dst_path.write_bytes(content) - with mock.patch( - "snapcraft.internal.repo.apt_sources_manager._sudo_write_file" - ) as m: - m.side_effect = write_file - yield m +@pytest.fixture(autouse=True) +def mock_run(mocker): + yield mocker.patch("subprocess.run") @pytest.fixture(autouse=True) -def mock_version_codename(): - with mock.patch( - "snapcraft.internal.os_release.OsRelease.version_codename", +def mock_version_codename(mocker): + yield mocker.patch( + "snapcraft.os_release.OsRelease.version_codename", return_value="FAKE-CODENAME", - ) as m: - yield m + ) @pytest.fixture @@ -90,55 +71,6 @@ def apt_sources_mgr(tmp_path): ) -@mock.patch("tempfile.NamedTemporaryFile") -@mock.patch("os.unlink") -def test_sudo_write_file(mock_unlink, mock_tempfile, mock_run, tmp_path): - mock_tempfile.return_value.__enter__.return_value.name = "/tmp/foobar" - - apt_sources_manager._sudo_write_file(dst_path="/foo/bar", content=b"some-content") - - assert mock_tempfile.mock_calls == [ - call(delete=False), - call().__enter__(), - call().__enter__().write(b"some-content"), - call().__enter__().flush(), - call().__exit__(None, None, None), - ] - assert mock_run.mock_calls == [ - call( - [ - "sudo", - "install", - "--owner=root", - "--group=root", - "--mode=0644", - "/tmp/foobar", - "/foo/bar", - ], - check=True, - ) - ] - assert mock_unlink.mock_calls == [call("/tmp/foobar")] - - -def test_sudo_write_file_fails(mock_run): - mock_run.side_effect = subprocess.CalledProcessError( - cmd=["sudo"], returncode=1, output=b"some error" - ) - - with pytest.raises(RuntimeError) as error: - apt_sources_manager._sudo_write_file( - dst_path="/foo/bar", content=b"some-content" - ) - - assert ( - str(error.value).startswith( - "Failed to install repository config with: ['sudo', 'install'" - ) - is True - ) - - @pytest.mark.parametrize( "package_repo,name,content", [ @@ -216,7 +148,7 @@ def test_sudo_write_file_fails(mock_run): ).encode(), ), ( - PackageRepositoryAptPpa(ppa="test/ppa"), + PackageRepositoryAptPPA(ppa="test/ppa"), "snapcraft-ppa-test_ppa.sources", dedent( """\ @@ -230,7 +162,7 @@ def test_sudo_write_file_fails(mock_run): ), ], ) -def test_install(package_repo, name, content, apt_sources_mgr, mock_sudo_write): +def test_install(package_repo, name, content, apt_sources_mgr): sources_path = apt_sources_mgr._sources_list_d / name changed = apt_sources_mgr.install_package_repository_sources( @@ -239,29 +171,22 @@ def test_install(package_repo, name, content, apt_sources_mgr, mock_sudo_write): assert changed is True assert sources_path.read_bytes() == content - assert mock_sudo_write.mock_calls == [ - call( - content=content, - dst_path=sources_path, - ) - ] # Verify a second-run does not incur any changes. - mock_sudo_write.reset_mock() - changed = apt_sources_mgr.install_package_repository_sources( package_repo=package_repo ) assert changed is False assert sources_path.read_bytes() == content - assert mock_sudo_write.mock_calls == [] def test_install_ppa_invalid(apt_sources_mgr): - repo = PackageRepositoryAptPpa(ppa="ppa-missing-slash") + repo = PackageRepositoryAptPPA(ppa="ppa-missing-slash") - with pytest.raises(errors.AptPPAInstallError) as exc_info: + with pytest.raises(errors.AptPPAInstallError) as raised: apt_sources_mgr.install_package_repository_sources(package_repo=repo) - assert exc_info.value._ppa == "ppa-missing-slash" + assert str(raised.value) == ( + "Failed to install PPA 'ppa-missing-slash': invalid PPA format" + ) diff --git a/tests/unit/repo/test_installer.py b/tests/unit/repo/test_installer.py new file mode 100644 index 0000000000..0c57fb2dc0 --- /dev/null +++ b/tests/unit/repo/test_installer.py @@ -0,0 +1,43 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from snapcraft.repo import installer +from snapcraft.repo.package_repository import ( + PackageRepositoryApt, + PackageRepositoryAptPPA, +) + + +def test_unmarshal_repositories(): + data = [ + { + "type": "apt", + "ppa": "test/somerepo", + }, + { + "type": "apt", + "url": "https://some/url", + "key-id": "ABCDE12345" * 4, + }, + ] + + pkg_repos = installer._unmarshal_repositories(data) + assert len(pkg_repos) == 2 + assert isinstance(pkg_repos[0], PackageRepositoryAptPPA) + assert pkg_repos[0].ppa == "test/somerepo" + assert isinstance(pkg_repos[1], PackageRepositoryApt) + assert pkg_repos[1].url == "https://some/url" + assert pkg_repos[1].key_id == "ABCDE12345" * 4 diff --git a/tests/unit/repo/test_package_repository.py b/tests/unit/repo/test_package_repository.py new file mode 100644 index 0000000000..45337bc232 --- /dev/null +++ b/tests/unit/repo/test_package_repository.py @@ -0,0 +1,426 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2019-2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +import pytest + +from snapcraft.repo import errors +from snapcraft.repo.package_repository import ( + PackageRepository, + PackageRepositoryApt, + PackageRepositoryAptPPA, +) + + +def test_apt_name(): + repo = PackageRepositoryApt( + architectures=["amd64", "i386"], + components=["main", "multiverse"], + formats=["deb", "deb-src"], + key_id="A" * 40, + key_server="keyserver.ubuntu.com", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert repo.name == "http_archive_ubuntu_com_ubuntu" + + +@pytest.mark.parametrize( + "arch", ["amd64", "armhf", "arm64", "i386", "ppc64el", "riscv", "s390x"] +) +def test_apt_valid_architectures(arch): + package_repo = PackageRepositoryApt( + key_id="A" * 40, url="http://test", architectures=[arch] + ) + + assert package_repo.architectures == [arch] + + +def test_apt_invalid_url(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + url="", + ) + + err = raised.value + assert str(err) == "Invalid package repository for '': invalid URL." + assert err.details == "URLs must be non-empty strings." + assert err.resolution == ( + "Verify the repository configuration and ensure that 'url' " + "is correctly specified." + ) + + +def test_apt_invalid_path(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + path="", + url="http://archive.ubuntu.com/ubuntu", + ) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "invalid path ''." + ) + assert err.details == "Paths must be non-empty strings." + assert err.resolution == ( + "Verify the repository configuration and ensure that 'path' " + "is a non-empty string such as '/'." + ) + + +def test_apt_invalid_path_with_suites(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + path="/", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "suites ['xenial', 'xenial-updates'] cannot be combined with path '/'." + ) + assert err.details == "Path and suites are incomptiable options." + assert err.resolution == ( + "Verify the repository configuration and remove 'path' or 'suites'." + ) + + +def test_apt_invalid_path_with_components(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + path="/", + components=["main"], + url="http://archive.ubuntu.com/ubuntu", + ) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "components ['main'] cannot be combined with path '/'." + ) + assert err.details == "Path and components are incomptiable options." + assert err.resolution == ( + "Verify the repository configuration and remove 'path' or 'components'." + ) + + +def test_apt_invalid_missing_components(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "no components specified." + ) + assert err.details == "Components are required when using suites." + assert err.resolution == ( + "Verify the repository configuration and ensure that 'components' " + "is correctly specified." + ) + + +def test_apt_invalid_missing_suites(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + components=["main"], + url="http://archive.ubuntu.com/ubuntu", + ) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "no suites specified." + ) + assert err.details == "Suites are required when using components." + assert err.resolution == ( + "Verify the repository configuration and ensure that 'suites' " + "is correctly specified." + ) + + +def test_apt_invalid_suites_as_path(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt( + key_id="A" * 40, + suites=["my-suite/"], + url="http://archive.ubuntu.com/ubuntu", + ) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "invalid suite 'my-suite/'." + ) + assert err.details == "Suites must not end with a '/'." + assert err.resolution == ( + "Verify the repository configuration and remove the trailing '/' " + "from suites or use the 'path' property to define a path." + ) + + +def test_apt_marshal(): + repo = PackageRepositoryApt( + architectures=["amd64", "i386"], + components=["main", "multiverse"], + formats=["deb", "deb-src"], + key_id="A" * 40, + key_server="xkeyserver.ubuntu.com", + name="test-name", + suites=["xenial", "xenial-updates"], + url="http://archive.ubuntu.com/ubuntu", + ) + + assert repo.marshal() == { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "xkeyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + +def test_apt_unmarshal_invalid_extra_keys(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + "foo": "bar", + "foo2": "bar", + } + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt.unmarshal(test_dict) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "unsupported properties 'foo', 'foo2'." + ) + assert err.details is None + assert err.resolution == "Verify repository configuration and ensure it is correct." + + +def test_apt_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt.unmarshal(test_dict) # type: ignore + + err = raised.value + assert str(err) == "Invalid package repository for 'not-a-dict': invalid object." + assert err.details == "Package repository must be a valid dictionary object." + assert err.resolution == ( + "Verify repository configuration and ensure that the correct syntax is used." + ) + + +def test_apt_unmarshal_invalid_type(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "aptx", + "url": "http://archive.ubuntu.com/ubuntu", + } + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryApt.unmarshal(test_dict) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'http://archive.ubuntu.com/ubuntu': " + "unsupported type 'aptx'." + ) + assert err.details == "The only currently supported type is 'apt'." + assert err.resolution == ( + "Verify repository configuration and ensure that 'type' is correctly specified." + ) + + +def test_ppa_marshal(): + repo = PackageRepositoryAptPPA(ppa="test/ppa") + + assert repo.marshal() == {"type": "apt", "ppa": "test/ppa"} + + +def test_ppa_invalid_ppa(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptPPA(ppa="") + + err = raised.value + assert str(err) == "Invalid package repository for '': invalid PPA." + assert err.details == "PPAs must be non-empty strings." + assert err.resolution == ( + "Verify repository configuration and ensure that 'ppa' is correctly specified." + ) + + +def test_ppa_unmarshal_invalid_data(): + test_dict = "not-a-dict" + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptPPA.unmarshal(test_dict) # type: ignore + + err = raised.value + assert str(err) == "Invalid package repository for 'not-a-dict': invalid object." + assert err.details == "Package repository must be a valid dictionary object." + assert err.resolution == ( + "Verify repository configuration and ensure that the correct syntax is used." + ) + + +def test_ppa_unmarshal_invalid_apt_ppa_type(): + test_dict = {"type": "aptx", "ppa": "test/ppa"} + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptPPA.unmarshal(test_dict) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'test/ppa': unsupported type 'aptx'." + ) + assert err.details == "The only currently supported type is 'apt'." + assert err.resolution == ( + "Verify repository configuration and ensure that 'type' is correctly specified." + ) + + +def test_ppa_unmarshal_invalid_apt_ppa_extra_keys(): + test_dict = {"type": "apt", "ppa": "test/ppa", "test": "foo"} + + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepositoryAptPPA.unmarshal(test_dict) + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'test/ppa': unsupported properties 'test'." + ) + assert err.details is None + assert err.resolution == ( + "Verify repository configuration and ensure that it is correct." + ) + + +def test_unmarshal_package_repositories_list_none(): + assert PackageRepository.unmarshal_package_repositories(None) == [] + + +def test_unmarshal_package_repositories_list_empty(): + assert PackageRepository.unmarshal_package_repositories([]) == [] + + +def test_unmarshal_package_repositories_list_ppa(): + test_dict = {"type": "apt", "ppa": "test/foo"} + test_list = [test_dict] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_list_apt(): + test_dict = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + test_list = [test_dict] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_list_all(): + test_ppa = {"type": "apt", "ppa": "test/foo"} + + test_deb = { + "architectures": ["amd64", "i386"], + "components": ["main", "multiverse"], + "formats": ["deb", "deb-src"], + "key-id": "A" * 40, + "key-server": "keyserver.ubuntu.com", + "name": "test-name", + "suites": ["xenial", "xenial-updates"], + "type": "apt", + "url": "http://archive.ubuntu.com/ubuntu", + } + + test_list = [test_ppa, test_deb] + + unmarshalled_list = [ + repo.marshal() + for repo in PackageRepository.unmarshal_package_repositories(test_list) + ] + + assert unmarshalled_list == test_list + + +def test_unmarshal_package_repositories_invalid_data(): + with pytest.raises(errors.PackageRepositoryValidationError) as raised: + PackageRepository.unmarshal_package_repositories("not-a-list") + + err = raised.value + assert str(err) == ( + "Invalid package repository for 'not-a-list': invalid list object." + ) + assert err.details == "Package repositories must be a list of objects." + assert err.resolution == ( + "Verify 'package-repositories' configuration and ensure that " + "the correct syntax is used." + ) diff --git a/tests/unit/repo/test_projects.py b/tests/unit/repo/test_projects.py new file mode 100644 index 0000000000..f04bc73623 --- /dev/null +++ b/tests/unit/repo/test_projects.py @@ -0,0 +1,134 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pydantic +import pytest + +from snapcraft.repo.projects import AptDeb, AptPPA + + +class TestAptPPAValidation: + """AptPPA field validation.""" + + def test_apt_ppa_valid(self): + repo = { + "type": "apt", + "ppa": "test/somerepo", + } + apt_ppa = AptPPA.unmarshal(repo) + assert apt_ppa.type == "apt" + assert apt_ppa.ppa == "test/somerepo" + + def test_apt_ppa_repository_invalid(self): + repo = { + "ppa": "test/somerepo", + } + error = r"type\s+field required" + with pytest.raises(pydantic.ValidationError, match=error): + AptPPA.unmarshal(repo) + + def test_project_package_ppa_repository_bad_type(self): + repo = { + "type": "invalid", + "ppa": "test/somerepo", + } + error = "unexpected value; permitted: 'apt'" + with pytest.raises(pydantic.ValidationError, match=error): + AptPPA.unmarshal(repo) + + +class TestAptDebValidation: + """AptDeb field validation.""" + + @pytest.mark.parametrize( + "repo", + [ + { + "type": "apt", + "url": "https://some/url", + "key-id": "BCDEF12345" * 4, + }, + { + "type": "apt", + "url": "https://some/url", + "key-id": "BCDEF12345" * 4, + "formats": ["deb"], + "components": ["some", "components"], + "key-server": "my-key-server", + "path": "my/path", + "suites": ["some", "suites"], + }, + ], + ) + def test_apt_deb_valid(self, repo): + apt_deb = AptDeb.unmarshal(repo) + assert apt_deb.type == "apt" + assert apt_deb.url == "https://some/url" + assert apt_deb.key_id == "BCDEF12345" * 4 + assert apt_deb.formats == (["deb"] if "formats" in repo else None) + assert apt_deb.components == ( + ["some", "components"] if "components" in repo else None + ) + assert apt_deb.key_server == ("my-key-server" if "key-server" in repo else None) + assert apt_deb.path == ("my/path" if "path" in repo else None) + assert apt_deb.suites == (["some", "suites"] if "suites" in repo else None) + + @pytest.mark.parametrize( + "key_id,error", + [ + ("ABCDE12345" * 4, None), + ("KEYID12345" * 4, "string does not match regex"), + ("abcde12345" * 4, "string does not match regex"), + ], + ) + def test_apt_deb_key_id(self, key_id, error): + repo = { + "type": "apt", + "url": "https://some/url", + "key-id": key_id, + } + + if not error: + apt_deb = AptDeb.unmarshal(repo) + assert apt_deb.key_id == key_id + else: + with pytest.raises(pydantic.ValidationError, match=error): + AptDeb.unmarshal(repo) + + @pytest.mark.parametrize( + "formats", + [ + ["deb"], + ["deb-src"], + ["deb", "deb-src"], + ["_invalid"], + ], + ) + def test_apt_deb_formats(self, formats): + repo = { + "type": "apt", + "url": "https://some/url", + "key-id": "ABCDE12345" * 4, + "formats": formats, + } + + if formats != ["_invalid"]: + apt_deb = AptDeb.unmarshal(repo) + assert apt_deb.formats == formats + else: + error = ".*unexpected value; permitted: 'deb', 'deb-src'" + with pytest.raises(pydantic.ValidationError, match=error): + AptDeb.unmarshal(repo) diff --git a/tests/unit/store/http_client/test_candid_client.py b/tests/unit/store/http_client/test_candid_client.py deleted file mode 100644 index 76390322c6..0000000000 --- a/tests/unit/store/http_client/test_candid_client.py +++ /dev/null @@ -1,342 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import io -import json -from textwrap import dedent -from unittest.mock import Mock, call, patch - -import pytest -from macaroonbakery import bakery, httpbakery -from pymacaroons.macaroon import Macaroon - -from snapcraft.storeapi.http_clients._candid_client import ( - CandidClient, - CandidConfig, - WebBrowserWaitingInteractor, - errors, - _http_client, -) - - -def test_config_section_name(): - assert CandidConfig()._get_section_name() == "dashboard.snapcraft.io" - - -def test_config_section_name_with_env(monkeypatch): - monkeypatch.setenv("STORE_DASHBOARD_URL", "http://dashboard.other.com") - - assert CandidConfig()._get_section_name() == "dashboard.other.com" - - -def test_config_path(xdg_dirs): - assert ( - CandidConfig()._get_config_path() == xdg_dirs / ".config/snapcraft/candid.cfg" - ) - - -def test_candid_client_has_no_credentials(xdg_dirs): - assert CandidClient().has_credentials() is False - - -def test_candid_client_has_credentials(xdg_dirs): - # Baseline check. - assert CandidClient().has_credentials() is False - - # Setup. - client = CandidClient() - client._macaroon = "macaroon" - client._auth = "auth" - - assert client.has_credentials() is True - assert CandidClient().has_credentials() is True - - -@pytest.fixture -def candid_client(xdg_dirs, monkeypatch): - """Return a CandidClient with an alterate requests method.""" - bakery_client = Mock(spec=httpbakery.Client) - - def mock_discharge(*args, **kwargs): - return [ - Macaroon( - location="api.snapcraft.io", - signature="d9533461d7835e4851c7e3b639144406cf768597dea6e133232fbd2385a5c050", - ) - ] - - monkeypatch.setattr(bakery, "discharge_all", mock_discharge) - - return CandidClient(bakery_client=bakery_client) - - -@pytest.fixture -def snapcraft_macaroon(): - return json.dumps( - { - "s64": "a0Vi7CwhHWjS4bxzKPhCZQIEJDvlbh9FyhOtWx0tNFQ", - "c": [ - {"i": "time-before 2022-03-18T19:54:57.151721Z"}, - { - "v64": "pDqaL9KDrPfCQCLDUdPc8yO2bTQheWGsM1tpxRaS_4BT3r6zpdnT5TelXz8vpjb4iUhTnc60-x5DPKJOpRuwAi4qMdNa67Vo", - "l": "https://api.jujucharms.com/identity/", - "i64": "AoZh2j7mbDQgh3oK3qMqoXKKFAnJvmOKwmDCNYHIxHqQnFLJZJUBpqoiJtqra-tyXPPMUTmfuXMgOWP7xKwTD26FBgtJBdh1mE1wt3kf0Ur_TnOzbAWQCHKxqK9jAp1jYv-LlLLAlQAmoqvz9fBf2--dIxHiLIRTThmAESAnlLZHOJ7praDmIScsLQC475a85avA", - }, - { - "i": 'extra {"package_id": null, "channel": null, "acl": ["package_access", "package_manage", "package_push", "package_register", "package_release", "package_update"], "store_ids": null}' - }, - ], - "l": "api.snapcraft.io", - "i64": "AwoQ2Ft5YBjnovqdr8VNV3TSlhIBMBoOCgVsb2dpbhIFbG9naW4", - } - ) - - -def test_login_discharge_macaroon(candid_client, snapcraft_macaroon): - candid_client.request - candid_client.login(macaroon=snapcraft_macaroon) - - assert candid_client.has_credentials() is True - assert candid_client._macaroon == snapcraft_macaroon - assert candid_client._auth == ( - "W3siaWRlbnRpZmllciI6ICIiLCAic2lnbmF0dXJlIjogImQ5NTMzNDYxZDc4MzVlNDg1MWM" - "3ZTNiNjM5MTQ0NDA2Y2Y3Njg1OTdkZWE2ZTEzMzIzMmZiZDIzODVhNWMwNTAiLCAibG9jYX" - "Rpb24iOiAiYXBpLnNuYXBjcmFmdC5pbyJ9XQ==" - ) - - -def test_login_discharge_macaroon_no_save(candid_client, snapcraft_macaroon): - candid_client.login(macaroon=snapcraft_macaroon, save=False) - - assert candid_client.has_credentials() is False - assert candid_client._macaroon == snapcraft_macaroon - assert candid_client._auth == ( - "W3siaWRlbnRpZmllciI6ICIiLCAic2lnbmF0dXJlIjogImQ5NTMzNDYxZDc4MzVlNDg1MWM" - "3ZTNiNjM5MTQ0NDA2Y2Y3Njg1OTdkZWE2ZTEzMzIzMmZiZDIzODVhNWMwNTAiLCAibG9jYX" - "Rpb24iOiAiYXBpLnNuYXBjcmFmdC5pbyJ9XQ==" - ) - - -def test_login_with_config_fd(candid_client, snapcraft_macaroon): - with io.StringIO() as config_fd: - print("[dashboard.snapcraft.io]", file=config_fd) - print(f"macaroon = {snapcraft_macaroon}", file=config_fd) - print("auth = 1234567890noshare", file=config_fd) - config_fd.seek(0) - - candid_client.login(config_fd=config_fd) - - assert candid_client.has_credentials() is True - assert candid_client._macaroon == snapcraft_macaroon - assert candid_client._auth == "1234567890noshare" - - -def test_login_with_config_fd_no_save(candid_client, snapcraft_macaroon): - with io.StringIO() as config_fd: - print("[dashboard.snapcraft.io]", file=config_fd) - print(f"macaroon = {snapcraft_macaroon}", file=config_fd) - print("auth = 1234567890noshare", file=config_fd) - config_fd.seek(0) - - candid_client.login(config_fd=config_fd, save=False) - - assert candid_client.has_credentials() is False - assert candid_client._macaroon == snapcraft_macaroon - assert candid_client._auth == "1234567890noshare" - - -@pytest.fixture -def authed_client(candid_client, snapcraft_macaroon): - candid_client.login(macaroon=snapcraft_macaroon) - assert candid_client.has_credentials() is True - - return candid_client - - -def test_logout(authed_client): - authed_client.logout() - - assert authed_client.has_credentials() is False - - -def test_export_login(authed_client): - with io.StringIO() as config_fd: - authed_client.export_login(config_fd=config_fd, encode=False) - - config_fd.seek(0) - - assert config_fd.getvalue().strip() == dedent( - f"""\ - [dashboard.snapcraft.io] - auth = {authed_client._auth} - macaroon = {authed_client._macaroon}""" - ) - - -def test_export_login_base64_encoded(authed_client): - with io.StringIO() as config_fd: - authed_client.export_login(config_fd=config_fd, encode=True) - - config_fd.seek(0) - - assert config_fd.getvalue().strip() == ( - "W2Rhc2hib2FyZC5zbmFwY3JhZnQuaW9dCmF1dGggPSBXM3NpYVdSbGJuUnBabWxsY2lJNklDSWlMQ0FpY" - "zJsbmJtRjBkWEpsSWpvZ0ltUTVOVE16TkRZeFpEYzRNelZsTkRnMU1XTTNaVE5pTmpNNU1UUTBOREEyWT" - "JZM05qZzFPVGRrWldFMlpURXpNekl6TW1aaVpESXpPRFZoTldNd05UQWlMQ0FpYkc5allYUnBiMjRpT2l" - "BaVlYQnBMbk51WVhCamNtRm1kQzVwYnlKOVhRPT0KbWFjYXJvb24gPSB7InM2NCI6ICJhMFZpN0N3aEhX" - "alM0Ynh6S1BoQ1pRSUVKRHZsYmg5RnloT3RXeDB0TkZRIiwgImMiOiBbeyJpIjogInRpbWUtYmVmb3JlI" - "DIwMjItMDMtMThUMTk6NTQ6NTcuMTUxNzIxWiJ9LCB7InY2NCI6ICJwRHFhTDlLRHJQZkNRQ0xEVWRQYz" - "h5TzJiVFFoZVdHc00xdHB4UmFTXzRCVDNyNnpwZG5UNVRlbFh6OHZwamI0aVVoVG5jNjAteDVEUEtKT3B" - "SdXdBaTRxTWROYTY3Vm8iLCAibCI6ICJodHRwczovL2FwaS5qdWp1Y2hhcm1zLmNvbS9pZGVudGl0eS8i" - "LCAiaTY0IjogIkFvWmgyajdtYkRRZ2gzb0szcU1xb1hLS0ZBbkp2bU9Ld21EQ05ZSEl4SHFRbkZMSlpKV" - "UJwcW9pSnRxcmEtdHlYUFBNVVRtZnVYTWdPV1A3eEt3VEQyNkZCZ3RKQmRoMW1FMXd0M2tmMFVyX1RuT3" - "piQVdRQ0hLeHFLOWpBcDFqWXYtTGxMTEFsUUFtb3F2ejlmQmYyLS1kSXhIaUxJUlRUaG1BRVNBbmxMWkh" - "PSjdwcmFEbUlTY3NMUUM0NzVhODVhdkEifSwgeyJpIjogImV4dHJhIHtcInBhY2thZ2VfaWRcIjogbnVs" - "bCwgXCJjaGFubmVsXCI6IG51bGwsIFwiYWNsXCI6IFtcInBhY2thZ2VfYWNjZXNzXCIsIFwicGFja2FnZ" - "V9tYW5hZ2VcIiwgXCJwYWNrYWdlX3B1c2hcIiwgXCJwYWNrYWdlX3JlZ2lzdGVyXCIsIFwicGFja2FnZV" - "9yZWxlYXNlXCIsIFwicGFja2FnZV91cGRhdGVcIl0sIFwic3RvcmVfaWRzXCI6IG51bGx9In1dLCAibCI" - "6ICJhcGkuc25hcGNyYWZ0LmlvIiwgImk2NCI6ICJBd29RMkZ0NVlCam5vdnFkcjhWTlYzVFNsaElCTUJv" - "T0NnVnNiMmRwYmhJRmJHOW5hVzQifQoK" - ) - - -@pytest.fixture -def request_mock(): - patched = patch.object( - _http_client.Client, "request", spec=_http_client.Client.request - ) - try: - yield patched.start() - finally: - patched.stop() - - -@pytest.fixture -def token_response_mock(): - class Response: - MOCK_JSON = { - "kind": "kind", - "token": "TOKEN", - "token64": b"VE9LRU42NA==", - } - - status_code = 200 - - def json(self): - return self.MOCK_JSON - - return Response() - - -def test_wait_for_token_success(request_mock, token_response_mock): - request_mock.return_value = token_response_mock - - wbi = WebBrowserWaitingInteractor() - discharged_token = wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") - - assert discharged_token.kind == "kind" - assert discharged_token.value == "TOKEN" - - -def test_wait_for_token64_success(request_mock, token_response_mock): - token_response_mock.MOCK_JSON.pop("token") - request_mock.return_value = token_response_mock - - wbi = WebBrowserWaitingInteractor() - discharged_token = wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") - - assert discharged_token.kind == "kind" - assert discharged_token.value == b"TOKEN64" - - -def test_wait_for_token_requests_status_not_200(request_mock, token_response_mock): - token_response_mock.status_code = 504 - request_mock.return_value = token_response_mock - - wbi = WebBrowserWaitingInteractor() - with pytest.raises(errors.TokenTimeoutError): - wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") - - -def test_wait_for_token_requests_no_kind(request_mock, token_response_mock): - token_response_mock.MOCK_JSON.pop("kind") - request_mock.return_value = token_response_mock - - wbi = WebBrowserWaitingInteractor() - with pytest.raises(errors.TokenKindError): - wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") - - -def test_wait_for_token_requests_no_token(request_mock, token_response_mock): - token_response_mock.MOCK_JSON.pop("token") - token_response_mock.MOCK_JSON.pop("token64") - request_mock.return_value = token_response_mock - - wbi = WebBrowserWaitingInteractor() - with pytest.raises(errors.TokenValueError): - wbi._wait_for_token(ctx=None, wait_token_url="https://localhost") - - -@pytest.mark.parametrize("method", ["GET", "PUT", "POST"]) -@pytest.mark.parametrize("params", [None, {}, {"foo": "bar"}]) -def test_request(authed_client, request_mock, method, params): - authed_client.request(method, "https://dashboard.snapcraft.io/foo", params=params) - - assert request_mock.mock_calls == [ - call( - method, - "https://dashboard.snapcraft.io/foo", - params=params, - headers={"Macaroons": authed_client._auth}, - ), - call().ok.__bool__(), - ] - - -def test_request_with_headers(authed_client, request_mock): - authed_client.request( - "GET", "https://dashboard.snapcraft.io/foo", headers={"foo": "bar"} - ) - - assert request_mock.mock_calls == [ - call( - "GET", - "https://dashboard.snapcraft.io/foo", - params=None, - headers={"foo": "bar", "Macaroons": authed_client._auth}, - ), - call().ok.__bool__(), - ] - - -@pytest.mark.parametrize("method", ["GET", "PUT", "POST"]) -@pytest.mark.parametrize("params", [None, {}, {"foo": "bar"}]) -@pytest.mark.parametrize("headers", [None, {}, {"foo": "bar"}]) -def test_request_no_auth(authed_client, request_mock, method, params, headers): - authed_client.request( - method, - "https://dashboard.snapcraft.io/foo", - params=params, - headers=headers, - auth_header=False, - ) - - assert request_mock.mock_calls == [ - call( - method, "https://dashboard.snapcraft.io/foo", params=params, headers=headers - ), - call().ok.__bool__(), - ] diff --git a/tests/unit/store/http_client/test_config.py b/tests/unit/store/http_client/test_config.py deleted file mode 100644 index 9bb641f858..0000000000 --- a/tests/unit/store/http_client/test_config.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import pathlib - -import pytest - -from snapcraft.storeapi.http_clients import errors -from snapcraft.storeapi.http_clients._config import Config - - -class ConfigImpl(Config): - def _get_section_name(self) -> str: - return "test-section" - - def _get_config_path(self) -> pathlib.Path: - return pathlib.Path("config.cfg") - - -@pytest.fixture -def conf(tmp_work_path): - conf = ConfigImpl() - yield conf - - -def test_non_existing_file_succeeds(conf): - assert conf.parser.sections() == [] - assert conf.is_section_empty() is True - - -def test_existing_file(conf): - conf.set("foo", "bar") - conf.save() - - conf.load() - - assert conf.get("foo") == "bar" - assert conf.is_section_empty() is False - - -def test_irrelevant_sections_are_ignored(conf): - with conf._get_config_path().open("w") as config_file: - print("[example.com]", file=config_file) - print("foo=bar", file=config_file) - - conf.load() - - assert conf.get("foo") is None - - -def test_clear_preserver_other_sections(conf): - with conf._get_config_path().open("w") as config_file: - print("[keep_me]", file=config_file) - print("foo=bar", file=config_file) - - conf.load() - conf.set("bar", "baz") - - assert conf.get("bar") == "baz" - - conf.clear() - conf.save() - conf.load() - - assert conf.get("bar") is None - assert conf.get("foo", "keep_me") == "bar" - assert conf.is_section_empty() is True - - -def test_save_encoded(conf): - conf.set("bar", "baz") - conf.save(encode=True) - conf.load() - - assert conf.get("bar") == "baz" - with conf._get_config_path().open() as config_file: - assert config_file.read() == "W3Rlc3Qtc2VjdGlvbl0KYmFyID0gYmF6Cgo=\n" - - -def test_save_encoded_other_config_file(conf): - conf.set("bar", "baz") - test_config_file = pathlib.Path("test-config") - with test_config_file.open("w") as config_fd: - conf.save(config_fd=config_fd, encode=True) - config_fd.flush() - - with test_config_file.open() as config_file: - assert config_file.read() == "W3Rlc3Qtc2VjdGlvbl0KYmFyID0gYmF6Cgo=\n" - - -def test_load_invalid_config(conf): - test_config_file = pathlib.Path("test-config") - with test_config_file.open("w") as config_fd: - print("invalid config", file=config_fd) - config_fd.flush() - - with test_config_file.open() as config_fd: - with pytest.raises(errors.InvalidLoginConfig): - conf.load(config_fd=config_fd) diff --git a/tests/unit/store/http_client/test_errors.py b/tests/unit/store/http_client/test_errors.py deleted file mode 100644 index 69453870d9..0000000000 --- a/tests/unit/store/http_client/test_errors.py +++ /dev/null @@ -1,138 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import requests -import urllib3 -from unittest import mock - -from snapcraft.storeapi.http_clients import errors - - -def _fake_error_response(status_code, reason): - response = mock.Mock() - response.status_code = status_code - response.reason = reason - return response - - -class TestSnapcraftException: - scenarios = ( - ( - "InvalidCredentialsError", - { - "exception_class": errors.InvalidCredentialsError, - "kwargs": {"message": "macaroon expired"}, - "expected_message": ( - "Invalid credentials: macaroon expired. " - 'Have you run "snapcraft login"?' - ), - }, - ), - ( - "StoreAuthenticationError", - { - "exception_class": errors.StoreAuthenticationError, - "kwargs": {"message": "invalid password"}, - "expected_message": ("Authentication error: invalid password"), - }, - ), - ( - "StoreNetworkError generic error", - { - "exception_class": errors.StoreNetworkError, - "kwargs": { - "exception": requests.exceptions.ConnectionError("bad error") - }, - "expected_message": "There seems to be a network error: bad error", - }, - ), - ( - "StoreNetworkError max retry error", - { - "exception_class": errors.StoreNetworkError, - "kwargs": { - "exception": requests.exceptions.ConnectionError( - urllib3.exceptions.MaxRetryError( - pool="test-pool", url="test-url" - ) - ) - }, - "expected_message": ( - "There seems to be a network error: maximum retries exceeded " - "trying to reach the store.\n" - "Check your network connection, and check the store status at " - "https://status.snapcraft.io/" - ), - }, - ), - ( - "StoreServerError 500", - { - "exception_class": errors.StoreServerError, - "kwargs": { - "response": _fake_error_response(500, "internal server error") - }, - "expected_message": ( - "The Snap Store encountered an error while processing your " - "request: internal server error (code 500).\nThe operational " - "status of the Snap Store can be checked at " - "https://status.snapcraft.io/" - ), - }, - ), - ( - "StoreServerError 501", - { - "exception_class": errors.StoreServerError, - "kwargs": {"response": _fake_error_response(501, "not implemented")}, - "expected_message": ( - "The Snap Store encountered an error while processing your " - "request: not implemented (code 501).\nThe operational " - "status of the Snap Store can be checked at " - "https://status.snapcraft.io/" - ), - }, - ), - ( - "TokenTimeoutError", - { - "exception_class": errors.TokenTimeoutError, - "kwargs": {"url": "https://foo"}, - "expected_message": ( - "Timed out waiting for token response from 'https://foo'." - ), - }, - ), - ( - "TokenKindError", - { - "exception_class": errors.TokenKindError, - "kwargs": {"url": "https://foo"}, - "expected_message": ("Empty token kind returned from 'https://foo'."), - }, - ), - ( - "TokenValueError", - { - "exception_class": errors.TokenValueError, - "kwargs": {"url": "https://foo"}, - "expected_message": ("Empty token value returned from 'https://foo'."), - }, - ), - ) - - def test_error_formatting(self, exception_class, expected_message, kwargs): - assert str(exception_class(**kwargs)) == expected_message diff --git a/tests/unit/store/http_client/test_ubuntu_one_auth_client.py b/tests/unit/store/http_client/test_ubuntu_one_auth_client.py deleted file mode 100644 index 1ea07805aa..0000000000 --- a/tests/unit/store/http_client/test_ubuntu_one_auth_client.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright 2021 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import pathlib - -import pymacaroons -import pytest - -from snapcraft.storeapi import http_clients - - -def test_invalid_macaroon_root_raises_exception(tmp_work_path): - with pathlib.Path("conf").open("w") as config_fd: - print("[login.ubuntu.com]", file=config_fd) - print("macaroon=inval'id", file=config_fd) - config_fd.flush() - - client = http_clients.UbuntuOneAuthClient() - with pathlib.Path("conf").open() as config_fd: - with pytest.raises(http_clients.errors.InvalidCredentialsError): - client.login(config_fd=config_fd) - - -def test_invalid_discharge_raises_exception(): - with pathlib.Path("conf").open("w") as config_fd: - print("[login.ubuntu.com]", file=config_fd) - print("macaroon={}".format(pymacaroons.Macaroon().serialize()), file=config_fd) - print("unbound_discharge=inval'id", file=config_fd) - config_fd.flush() - - client = http_clients.UbuntuOneAuthClient() - - with pathlib.Path("conf").open() as config_fd: - with pytest.raises(http_clients.errors.InvalidCredentialsError): - client.login(config_fd=config_fd) diff --git a/tests/unit/store/v2/test_channel_map.py b/tests/unit/store/v2/test_channel_map.py deleted file mode 100644 index 22d8bb4c47..0000000000 --- a/tests/unit/store/v2/test_channel_map.py +++ /dev/null @@ -1,396 +0,0 @@ -# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- -# -# Copyright (C) 2020 Canonical Ltd -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import pytest -from testtools.matchers import Equals, HasLength, Is, IsInstance - -from snapcraft.storeapi.v2 import channel_map -from tests import unit - - -class ProgressiveTest(unit.TestCase): - def test_progressive(self): - payload = {"paused": False, "percentage": 83.3, "current-percentage": 32.1} - - p = channel_map.Progressive.unmarshal(payload) - - self.expectThat(repr(p), Equals(f"83.3>")) - self.expectThat(p.paused, Equals(payload["paused"])) - self.expectThat(p.percentage, Equals(payload["percentage"])) - self.expectThat(p.current_percentage, Equals(payload["current-percentage"])) - self.expectThat(p.marshal(), Equals(payload)) - - def test_none(self): - payload = {"paused": None, "percentage": None, "current-percentage": None} - - p = channel_map.Progressive.unmarshal(payload) - - self.expectThat(repr(p), Equals(f"None>")) - self.expectThat(p.paused, Equals(payload["paused"])) - self.expectThat(p.percentage, Equals(payload["percentage"])) - self.expectThat(p.current_percentage, Equals(payload["current-percentage"])) - self.expectThat(p.marshal(), Equals(payload)) - - -class MappedChannelTest(unit.TestCase): - def setUp(self): - super().setUp() - - self.payload = { - "architecture": "amd64", - "channel": "latest/stable", - "expiration-date": None, - "progressive": { - "paused": None, - "percentage": None, - "current-percentage": None, - }, - "revision": 2, - } - - def test_channel(self): - mc = channel_map.MappedChannel.unmarshal(self.payload) - - self.expectThat( - repr(mc), - Equals( - "" - ), - ) - self.expectThat(mc.channel, Equals(self.payload["channel"])) - self.expectThat(mc.revision, Equals(self.payload["revision"])) - self.expectThat(mc.architecture, Equals(self.payload["architecture"])) - self.expectThat(mc.progressive, IsInstance(channel_map.Progressive)) - self.expectThat(mc.expiration_date, Is(None)) - self.expectThat(mc.marshal(), Equals(self.payload)) - - def test_channel_with_expiration(self): - date_string = "2020-02-11T17:51:40.891996Z" - self.payload.update({"expiration-date": date_string}) - - mc = channel_map.MappedChannel.unmarshal(self.payload) - - self.expectThat( - repr(mc), - Equals( - "" - ), - ) - self.expectThat(mc.channel, Equals(self.payload["channel"])) - self.expectThat(mc.revision, Equals(self.payload["revision"])) - self.expectThat(mc.architecture, Equals(self.payload["architecture"])) - self.expectThat(mc.progressive, IsInstance(channel_map.Progressive)) - self.expectThat(mc.expiration_date, Equals(date_string)) - self.expectThat(mc.marshal(), Equals(self.payload)) - - -class SnapChannelTest(unit.TestCase): - def setUp(self): - super().setUp() - - self.payload = { - "name": "latest/candidate", - "track": "latest", - "risk": "candidate", - "branch": None, - "fallback": None, - } - - def test_channel(self): - sc = channel_map.SnapChannel.unmarshal(self.payload) - - self.expectThat(repr(sc), Equals("")) - self.expectThat(sc.name, Equals(self.payload["name"])) - self.expectThat(sc.track, Equals(self.payload["track"])) - self.expectThat(sc.risk, Equals(self.payload["risk"])) - self.expectThat(sc.branch, Is(None)) - self.expectThat(sc.fallback, Is(None)) - self.expectThat(sc.marshal(), Equals(self.payload)) - - def test_channel_with_branch(self): - self.payload.update({"branch": "test-branch"}) - - sc = channel_map.SnapChannel.unmarshal(self.payload) - - self.expectThat(repr(sc), Equals("")) - self.expectThat(sc.name, Equals(self.payload["name"])) - self.expectThat(sc.track, Equals(self.payload["track"])) - self.expectThat(sc.risk, Equals(self.payload["risk"])) - self.expectThat(sc.branch, Equals(self.payload["branch"])) - self.expectThat(sc.fallback, Is(None)) - self.expectThat(sc.marshal(), Equals(self.payload)) - - def test_channel_with_fallback(self): - self.payload.update({"fallback": "latest/stable"}) - - sc = channel_map.SnapChannel.unmarshal(self.payload) - - self.expectThat(repr(sc), Equals("")) - self.expectThat(sc.name, Equals(self.payload["name"])) - self.expectThat(sc.track, Equals(self.payload["track"])) - self.expectThat(sc.risk, Equals(self.payload["risk"])) - self.expectThat(sc.branch, Is(None)), - self.expectThat(sc.fallback, Equals(self.payload["fallback"])) - self.expectThat(sc.marshal(), Equals(self.payload)) - - -_TRACK_PAYLOADS = [ - { - "name": "latest", - "status": "active", - "creation-date": None, - "version-pattern": None, - }, - { - "name": "1.0", - "status": "default", - "creation-date": "2019-10-17T14:11:59Z", - "version-pattern": "1.*", - }, -] - - -@pytest.mark.parametrize("payload", _TRACK_PAYLOADS) -def test_snap_track(payload): - st = channel_map.SnapTrack.unmarshal(payload) - - assert repr(st) == f"" - assert st.name == payload["name"] - assert st.status == payload["status"] - assert st.creation_date == payload["creation-date"] - assert st.version_pattern == payload["version-pattern"] - assert st.marshal() == payload - - -class RevisionTest(unit.TestCase): - def test_revision(self): - payload = {"revision": 2, "version": "2.0", "architectures": ["amd64", "arm64"]} - - r = channel_map.Revision.unmarshal(payload) - - self.expectThat( - repr(r), - Equals( - "" - ), - ) - self.expectThat(r.revision, Equals(payload["revision"])) - self.expectThat(r.version, Equals(payload["version"])) - self.expectThat(r.architectures, Equals(payload["architectures"])) - self.expectThat(r.marshal(), Equals(payload)) - - -class SnapTest(unit.TestCase): - def test_snap(self): - payload = { - "name": "my-snap", - "channels": [ - { - "name": "latest/stable", - "track": "latest", - "risk": "candidate", - "branch": None, - "fallback": None, - }, - { - "name": "latest/candidate", - "track": "latest", - "risk": "candidate", - "branch": None, - "fallback": "latest/stable", - }, - ], - "tracks": [ - { - "name": "track1", - "creation-date": "2019-10-17T14:11:59Z", - "status": "default", - "version-pattern": None, - }, - { - "name": "track2", - "creation-date": None, - "status": "active", - "version-pattern": None, - }, - ], - } - - s = channel_map.Snap.unmarshal(payload) - - self.expectThat(repr(s), Equals("")) - self.expectThat(s.name, Equals(payload["name"])) - - snap_channels = s.channels - self.expectThat(snap_channels, HasLength(2)) - self.expectThat(snap_channels[0], IsInstance(channel_map.SnapChannel)) - self.expectThat(snap_channels[1], IsInstance(channel_map.SnapChannel)) - - self.expectThat(s.marshal(), Equals(payload)) - - -class ChannelMapTest(unit.TestCase): - def test_channel_map(self): - payload = { - "channel-map": [ - { - "architecture": "amd64", - "channel": "latest/stable", - "expiration-date": None, - "progressive": { - "paused": None, - "percentage": None, - "current-percentage": None, - }, - "revision": 2, - }, - { - "architecture": "amd64", - "channel": "latest/stable", - "expiration-date": None, - "progressive": { - "paused": None, - "percentage": 33.3, - "current-percentage": 12.3, - }, - "revision": 3, - }, - { - "architecture": "arm64", - "channel": "latest/stable", - "expiration-date": None, - "progressive": { - "paused": None, - "percentage": None, - "current-percentage": None, - }, - "revision": 2, - }, - { - "architecture": "i386", - "channel": "latest/stable", - "expiration-date": None, - "progressive": { - "paused": None, - "percentage": None, - "current-percentage": None, - }, - "revision": 4, - }, - ], - "revisions": [ - {"revision": 2, "version": "2.0", "architectures": ["amd64", "arm64"]}, - {"revision": 3, "version": "2.0", "architectures": ["amd64", "arm64"]}, - {"revision": 4, "version": "2.0", "architectures": ["i386"]}, - ], - "snap": { - "name": "my-snap", - "channels": [ - { - "name": "latest/stable", - "track": "latest", - "risk": "candidate", - "branch": None, - "fallback": None, - }, - { - "name": "latest/candidate", - "track": "latest", - "risk": "candidate", - "branch": None, - "fallback": "latest/stable", - }, - ], - "tracks": [ - { - "name": "track1", - "creation-date": "2019-10-17T14:11:59Z", - "status": "default", - "version-pattern": None, - }, - { - "name": "track2", - "creation-date": None, - "status": "active", - "version-pattern": None, - }, - ], - }, - } - - cm = channel_map.ChannelMap.unmarshal(payload) - - # Check "channel-map". - self.expectThat(cm.channel_map, HasLength(4)) - self.expectThat(cm.channel_map[0], IsInstance(channel_map.MappedChannel)) - self.expectThat(cm.channel_map[1], IsInstance(channel_map.MappedChannel)) - self.expectThat(cm.channel_map[2], IsInstance(channel_map.MappedChannel)) - self.expectThat(cm.channel_map[3], IsInstance(channel_map.MappedChannel)) - - # Check "revisions". - self.expectThat(cm.revisions, HasLength(3)) - self.expectThat(cm.revisions[0], IsInstance(channel_map.Revision)) - self.expectThat(cm.revisions[1], IsInstance(channel_map.Revision)) - self.expectThat(cm.revisions[2], IsInstance(channel_map.Revision)) - - # Check "snap". - self.expectThat(cm.snap, IsInstance(channel_map.Snap)) - - # Marshal. - self.expectThat(cm.marshal(), Equals(payload)) - - # Test the get_mapped_channel method. - self.expectThat( - cm.get_mapped_channel( - channel_name="latest/stable", architecture="amd64", progressive=False - ), - Equals(cm.channel_map[0]), - ) - self.expectThat( - cm.get_mapped_channel( - channel_name="latest/stable", architecture="amd64", progressive=True - ), - Equals(cm.channel_map[1]), - ) - self.assertRaises( - ValueError, - cm.get_mapped_channel, - channel_name="latest/stable", - architecture="arm64", - progressive=True, - ) - self.assertRaises( - ValueError, - cm.get_mapped_channel, - channel_name="latest/stable", - architecture="i386", - progressive=True, - ) - - # Test the get_channel_info method. - self.expectThat( - cm.get_channel_info("latest/stable"), Equals(cm.snap.channels[0]) - ) - self.assertRaises(ValueError, cm.get_channel_info, "other-track/stable") - - # Test the get_revision method. - self.expectThat(cm.get_revision(4), Equals(cm.revisions[2])) - self.assertRaises(ValueError, cm.get_revision, 5) - - # Test the get_existing_architectures method. - self.expectThat( - cm.get_existing_architectures(), Equals(set(["arm64", "amd64", "i386"])) - ) diff --git a/tests/unit/test_os_release.py b/tests/unit/test_os_release.py index da25cfa4cc..80116c9cf0 100644 --- a/tests/unit/test_os_release.py +++ b/tests/unit/test_os_release.py @@ -1,6 +1,6 @@ # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # -# Copyright (C) 2015-2018 Canonical Ltd +# Copyright 2017-2022 Canonical Ltd. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License version 3 as @@ -14,126 +14,135 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from pathlib import Path from textwrap import dedent -from testtools.matchers import Equals +import pytest -from snapcraft.internal import errors, os_release -from tests import unit +from snapcraft import errors, os_release -class OsReleaseTestCase(unit.TestCase): - def _write_os_release(self, contents): - path = "os-release" - with open(path, "w") as f: - f.write(contents) - return path +@pytest.fixture +def _os_release(new_dir): + def _release_data(contents): + path = Path("os-release") + path.write_text(contents) + release = os_release.OsRelease(os_release_file=path) + return release - def test_blank_lines(self): - release = os_release.OsRelease( - os_release_file=self._write_os_release( - dedent( - """\ - NAME="Arch Linux" + return _release_data - PRETTY_NAME="Arch Linux" - ID=arch - ID_LIKE=archlinux - VERSION_ID="foo" - VERSION_CODENAME="bar" + +def test_blank_lines(_os_release): + release = _os_release( + dedent( + """\ + NAME="Arch Linux" + + PRETTY_NAME="Arch Linux" + ID=arch + ID_LIKE=archlinux + VERSION_ID="foo" + VERSION_CODENAME="bar" """ - ) - ) ) - - self.assertThat(release.id(), Equals("arch")) - self.assertThat(release.name(), Equals("Arch Linux")) - self.assertThat(release.version_id(), Equals("foo")) - self.assertThat(release.version_codename(), Equals("bar")) - - def test_no_id(self): - release = os_release.OsRelease( - os_release_file=self._write_os_release( - dedent( - """\ - NAME="Arch Linux" - PRETTY_NAME="Arch Linux" - ID_LIKE=archlinux - VERSION_ID="foo" - VERSION_CODENAME="bar" + ) + + assert release.id() == "arch" + assert release.name() == "Arch Linux" + assert release.version_id() == "foo" + assert release.version_codename() == "bar" + + +def test_no_id(_os_release): + release = _os_release( + dedent( + """\ + NAME="Arch Linux" + PRETTY_NAME="Arch Linux" + ID_LIKE=archlinux + VERSION_ID="foo" + VERSION_CODENAME="bar" """ - ) - ) ) + ) + + with pytest.raises(errors.SnapcraftError) as raised: + release.id() - self.assertRaises(errors.OsReleaseIdError, release.id) - - def test_no_name(self): - release = os_release.OsRelease( - os_release_file=self._write_os_release( - dedent( - """\ - ID=arch - PRETTY_NAME="Arch Linux" - ID_LIKE=archlinux - VERSION_ID="foo" - VERSION_CODENAME="bar" + assert str(raised.value) == "Unable to determine host OS ID" + + +def test_no_name(_os_release): + release = _os_release( + dedent( + """\ + ID=arch + PRETTY_NAME="Arch Linux" + ID_LIKE=archlinux + VERSION_ID="foo" + VERSION_CODENAME="bar" """ - ) - ) ) + ) + + with pytest.raises(errors.SnapcraftError) as raised: + release.name() + + assert str(raised.value) == "Unable to determine host OS name" - self.assertRaises(errors.OsReleaseNameError, release.name) - - def test_no_version_id(self): - release = os_release.OsRelease( - os_release_file=self._write_os_release( - dedent( - """\ - NAME="Arch Linux" - ID=arch - PRETTY_NAME="Arch Linux" - ID_LIKE=archlinux - VERSION_CODENAME="bar" + +def test_no_version_id(_os_release): + release = _os_release( + dedent( + """\ + NAME="Arch Linux" + ID=arch + PRETTY_NAME="Arch Linux" + ID_LIKE=archlinux + VERSION_CODENAME="bar" """ - ) - ) ) + ) + + with pytest.raises(errors.SnapcraftError) as raised: + release.version_id() + + assert str(raised.value) == "Unable to determine host OS version ID" + - self.assertRaises(errors.OsReleaseVersionIdError, release.version_id) - - def test_no_version_codename(self): - """Test that version codename can also come from VERSION_ID""" - release = os_release.OsRelease( - os_release_file=self._write_os_release( - dedent( - """\ - NAME="Ubuntu" - VERSION="14.04.5 LTS, Trusty Tahr" - ID=ubuntu - ID_LIKE=debian - PRETTY_NAME="Ubuntu 14.04.5 LTS" - VERSION_ID="14.04" +def test_no_version_codename(_os_release): + """Test that version codename can also come from VERSION_ID""" + release = _os_release( + dedent( + """\ + NAME="Ubuntu" + VERSION="14.04.5 LTS, Trusty Tahr" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 14.04.5 LTS" + VERSION_ID="14.04" """ - ) - ) ) + ) - self.assertThat(release.version_codename(), Equals("trusty")) - - def test_no_version_codename_or_version_id(self): - release = os_release.OsRelease( - os_release_file=self._write_os_release( - dedent( - """\ - NAME="Ubuntu" - ID=ubuntu - ID_LIKE=debian - PRETTY_NAME="Ubuntu 16.04.3 LTS" + assert release.version_codename() == "trusty" + + +def test_no_version_codename_or_version_id(_os_release): + release = _os_release( + dedent( + """\ + NAME="Ubuntu" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 16.04.3 LTS" """ - ) - ) ) + ) + + with pytest.raises(errors.SnapcraftError) as raised: + release.version_codename() - self.assertRaises(errors.OsReleaseCodenameError, release.version_codename) + assert str(raised.value) == "Unable to determine host OS version codename" diff --git a/tests/unit/test_pack.py b/tests/unit/test_pack.py new file mode 100644 index 0000000000..87158bc2b3 --- /dev/null +++ b/tests/unit/test_pack.py @@ -0,0 +1,97 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import subprocess +from unittest.mock import call + +import pytest + +from snapcraft import errors, pack + + +def test_pack_snap(mocker, new_dir): + mock_run = mocker.patch("subprocess.run") + pack.pack_snap(new_dir, output=None) + assert mock_run.mock_calls == [ + call( + ["snap", "pack", new_dir], + capture_output=True, + check=True, + universal_newlines=True, + ) + ] + + +def test_pack_snap_compression_none(mocker, new_dir): + mock_run = mocker.patch("subprocess.run") + pack.pack_snap(new_dir, output=None, compression=None) + assert mock_run.mock_calls == [ + call( + ["snap", "pack", new_dir], + capture_output=True, + check=True, + universal_newlines=True, + ) + ] + + +def test_pack_snap_compression(mocker, new_dir): + mock_run = mocker.patch("subprocess.run") + pack.pack_snap(new_dir, output=None, compression="zz") + assert mock_run.mock_calls == [ + call( + ["snap", "pack", "--compression", "zz", new_dir], + capture_output=True, + check=True, + universal_newlines=True, + ) + ] + + +def test_pack_snap_output_file(mocker, new_dir): + mock_run = mocker.patch("subprocess.run") + pack.pack_snap(new_dir, output="/tmp/foo") + assert mock_run.mock_calls == [ + call( + ["snap", "pack", "--filename", "foo", new_dir, "/tmp"], + capture_output=True, + check=True, + universal_newlines=True, + ) + ] + + +def test_pack_snap_output_dir(mocker, new_dir): + mock_run = mocker.patch("subprocess.run") + pack.pack_snap(new_dir, output=str(new_dir)) + assert mock_run.mock_calls == [ + call( + ["snap", "pack", new_dir, str(new_dir)], + capture_output=True, + check=True, + universal_newlines=True, + ) + ] + + +def test_pack_snap_error(mocker, new_dir): + mocker.patch("subprocess.run", side_effect=subprocess.CalledProcessError(42, "cmd")) + with pytest.raises(errors.SnapcraftError) as raised: + pack.pack_snap(new_dir, output=str(new_dir)) + + assert str(raised.value) == ( + "Cannot pack snap file: Command 'cmd' returned non-zero exit status 42." + ) diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py new file mode 100644 index 0000000000..9eadff80a1 --- /dev/null +++ b/tests/unit/test_projects.py @@ -0,0 +1,1136 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from typing import Any, Dict + +import pydantic +import pytest + +from snapcraft import errors +from snapcraft.projects import ( + MANDATORY_ADOPTABLE_FIELDS, + ContentPlug, + GrammarAwareProject, + Hook, + Project, +) + +# pylint: disable=too-many-lines + + +@pytest.fixture +def project_yaml_data(): + def _project_yaml_data( + *, name: str = "name", version: str = "0.1", summary: str = "summary", **kwargs + ) -> Dict[str, Any]: + return { + "name": name, + "version": version, + "base": "core22", + "summary": summary, + "description": "description", + "grade": "stable", + "confinement": "strict", + "parts": {}, + **kwargs, + } + + yield _project_yaml_data + + +@pytest.fixture +def app_yaml_data(project_yaml_data): + def _app_yaml_data(**kwargs) -> Dict[str, Any]: + data = project_yaml_data() + data["apps"] = {"app1": {"command": "/bin/true", **kwargs}} + return data + + yield _app_yaml_data + + +@pytest.fixture +def socket_yaml_data(app_yaml_data): + def _socket_yaml_data(**kwargs) -> Dict[str, Any]: + data = app_yaml_data() + data["apps"]["app1"]["sockets"] = {"socket1": {**kwargs}} + return data + + yield _socket_yaml_data + + +class TestProjectDefaults: + """Ensure unspecified items have the correct default value.""" + + def test_project_defaults(self, project_yaml_data): + project = Project.unmarshal(project_yaml_data()) + + assert project.build_base == project.base + assert project.compression == "xz" + assert project.contact is None + assert project.donation is None + assert project.issues is None + assert project.source_code is None + assert project.website is None + assert project.type is None + assert project.icon is None + assert project.layout is None + assert project.license is None + assert project.architectures == [] + assert project.package_repositories == [] + assert project.assumes == [] + assert project.hooks is None + assert project.passthrough is None + assert project.apps is None + assert project.plugs is None + assert project.slots is None + assert project.epoch is None + assert project.environment is None + assert project.adopt_info is None + + def test_app_defaults(self, project_yaml_data): + data = project_yaml_data(apps={"app1": {"command": "/bin/true"}}) + project = Project.unmarshal(data) + assert project.apps is not None + + app = project.apps["app1"] + assert app is not None + + assert app.command == "/bin/true" + assert app.autostart is None + assert app.common_id is None + assert app.bus_name is None + assert app.completer is None + assert app.stop_command is None + assert app.post_stop_command is None + assert app.start_timeout is None + assert app.stop_timeout is None + assert app.watchdog_timeout is None + assert app.reload_command is None + assert app.restart_delay is None + assert app.timer is None + assert app.daemon is None + assert app.after == [] + assert app.before == [] + assert app.refresh_mode is None + assert app.stop_mode is None + assert app.restart_condition is None + assert app.install_mode is None + assert app.slots is None + assert app.plugs is None + assert app.aliases is None + assert app.environment is None + assert app.command_chain == [] + + +class TestProjectValidation: + """Validate top-level project items.""" + + @pytest.mark.parametrize("field", ["name", "confinement", "parts"]) + def test_mandatory_fields(self, field, project_yaml_data): + data = project_yaml_data() + data.pop(field) + error = f"field {field!r} required in top-level configuration" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "snap_type,requires_base", + [ + ("app", True), + ("gadget", True), + ("base", False), + ("kernel", False), + ("snapd", False), + ], + ) + def test_mandatory_base(self, snap_type, requires_base, project_yaml_data): + data = project_yaml_data(type=snap_type) + data.pop("base") + + if requires_base: + error = "Snap base must be declared when type is not" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + else: + project = Project.unmarshal(data) + assert project.base is None + + def test_mandatory_adoptable_fields_definition(self): + assert MANDATORY_ADOPTABLE_FIELDS == ( + "version", + "summary", + "description", + ) + + @pytest.mark.parametrize("field", MANDATORY_ADOPTABLE_FIELDS) + def test_adoptable_fields(self, field, project_yaml_data): + data = project_yaml_data() + data.pop(field) + error = f"Snap {field} is required if not using adopt-info" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("field", MANDATORY_ADOPTABLE_FIELDS) + def test_adoptable_field_not_required(self, field, project_yaml_data): + data = project_yaml_data() + data.pop(field) + data["adopt-info"] = "part1" + project = Project.unmarshal(data) + assert getattr(project, field) is None + + @pytest.mark.parametrize("field", MANDATORY_ADOPTABLE_FIELDS) + def test_adoptable_field_assignment(self, field, project_yaml_data): + data = project_yaml_data() + project = Project.unmarshal(data) + setattr(project, field, None) + + @pytest.mark.parametrize( + "name", + [ + "name", + "name-with-dashes", + "name0123", + "0123name", + "a234567890123456789012345678901234567890", + ], + ) + def test_project_name_valid(self, name, project_yaml_data): + project = Project.unmarshal(project_yaml_data(name=name)) + assert project.name == name + + @pytest.mark.parametrize( + "name,error", + [ + ("name_with_underscores", "Snap names can only use"), + ("name-with-UPPERCASE", "Snap names can only use"), + ("name with spaces", "Snap names can only use"), + ("-name-starts-with-hyphen", "Snap names cannot start with a hyphen"), + ("name-ends-with-hyphen-", "Snap names cannot end with a hyphen"), + ("name-has--two-hyphens", "Snap names cannot have two hyphens in a row"), + ("123456", "Snap names can only use"), + ( + "a2345678901234567890123456789012345678901", + "ensure this value has at most 40 characters", + ), + ], + ) + def test_project_name_invalid(self, name, error, project_yaml_data): + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(name=name)) + + @pytest.mark.parametrize( + "version", + [ + "1", + "1.0", + "1.0.1-5.2~build0.20.04:1+1A", + "git", + "1~", + "1+", + "12345678901234567890123456789012", + ], + ) + def test_project_version_valid(self, version, project_yaml_data): + project = Project.unmarshal(project_yaml_data(version=version)) + assert project.version == version + + @pytest.mark.parametrize( + "version,error", + [ + ("1_0", "Snap versions consist of"), # _ is an invalid character + ("1=1", "Snap versions consist of"), # = is an invalid character + (".1", "Snap versions consist of"), # cannot start with period + (":1", "Snap versions consist of"), # cannot start with colon + ("+1", "Snap versions consist of"), # cannot start with plus sign + ("~1", "Snap versions consist of"), # cannot start with tilde + ("-1", "Snap versions consist of"), # cannot start with hyphen + ("1.", "Snap versions consist of"), # cannot end with period + ("1:", "Snap versions consist of"), # cannot end with colon + ("1-", "Snap versions consist of"), # cannot end with hyphen + ( + "123456789012345678901234567890123", + "ensure this value has at most 32 characters", + ), # too large + ], + ) + def test_project_version_invalid(self, version, error, project_yaml_data): + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(version=version)) + + @pytest.mark.parametrize( + "snap_type", + ["app", "gadget", "kernel", "snapd", "base", "_invalid"], + ) + def test_project_type(self, snap_type, project_yaml_data): + data = project_yaml_data(type=snap_type) + if snap_type in ["base", "kernel", "snapd"]: + data.pop("base") + + if snap_type != "_invalid": + project = Project.unmarshal(data) + assert project.type == snap_type + else: + error = ".*unexpected value; permitted: 'app', 'base', 'gadget', 'kernel', 'snapd'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "confinement", ["strict", "devmode", "classic", "_invalid"] + ) + def test_project_confinement(self, confinement, project_yaml_data): + data = project_yaml_data(confinement=confinement) + + if confinement != "_invalid": + project = Project.unmarshal(data) + assert project.confinement == confinement + else: + error = ".*unexpected value; permitted: 'classic', 'devmode', 'strict'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("grade", ["devel", "stable", "_invalid"]) + def test_project_grade(self, grade, project_yaml_data): + data = project_yaml_data(grade=grade) + + if grade != "_invalid": + project = Project.unmarshal(data) + assert project.grade == grade + else: + error = ".*unexpected value; permitted: 'stable', 'devel'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("grade", ["devel", "stable", "_invalid"]) + def test_project_grade_assignment(self, grade, project_yaml_data): + data = project_yaml_data() + + project = Project.unmarshal(data) + if grade != "_invalid": + project.grade = grade + else: + error = ".*unexpected value; permitted: 'stable', 'devel'" + with pytest.raises(pydantic.ValidationError, match=error): + project.grade = grade + + def test_project_summary_valid(self, project_yaml_data): + summary = "x" * 78 + project = Project.unmarshal(project_yaml_data(summary=summary)) + assert project.summary == summary + + def test_project_summary_invalid(self, project_yaml_data): + summary = "x" * 79 + error = "ensure this value has at most 78 characters" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(summary=summary)) + + @pytest.mark.parametrize( + "epoch", + [ + "0", + "1", + "1*", + "12345", + "12345*", + ], + ) + def test_project_epoch_valid(self, epoch, project_yaml_data): + project = Project.unmarshal(project_yaml_data(epoch=epoch)) + assert project.epoch == epoch + + @pytest.mark.parametrize( + "epoch", + [ + "", + "invalid", + "0*", + "012345", + "-1", + "*1", + "1**", + ], + ) + def test_project_epoch_invalid(self, epoch, project_yaml_data): + error = "Epoch is a positive integer followed by an optional asterisk" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(epoch=epoch)) + + def test_project_package_repository(self, project_yaml_data): + repos = [ + { + "type": "apt", + "ppa": "test/somerepo", + }, + { + "type": "apt", + "url": "https://some/url", + "key-id": "ABCDE12345" * 4, + }, + ] + project = Project.unmarshal(project_yaml_data(package_repositories=repos)) + assert project.package_repositories == repos + + def test_project_package_repository_missing_fields(self, project_yaml_data): + repos = [ + { + "type": "apt", + }, + ] + error = r".*- field 'url' required .*\n- field 'key-id' required" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(package_repositories=repos)) + + def test_project_package_repository_extra_fields(self, project_yaml_data): + repos = [ + { + "type": "apt", + "extra": "something", + }, + ] + error = r".*- extra field 'extra' not permitted" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(package_repositories=repos)) + + @pytest.mark.parametrize( + "environment", + [ + {"SINGLE_VARIABLE": "foo"}, + {"FIRST_VARIABLE": "foo", "SECOND_VARIABLE": "bar"}, + ], + ) + def test_project_environment_valid(self, environment, project_yaml_data): + project = Project.unmarshal(project_yaml_data(environment=environment)) + assert project.environment == environment + + @pytest.mark.parametrize( + "environment", + [ + "i am a string", + ["i", "am", "a", "list"], + [{"i": "am"}, {"a": "list"}, {"of": "dictionaries"}], + ], + ) + def test_project_environment_invalid(self, environment, project_yaml_data): + error = ".*value is not a valid dict" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(environment=environment)) + + @pytest.mark.parametrize( + "plugs", + [ + {"empty-plug": None}, + {"string-plug": "home"}, + {"dict-plug": {"string-parameter": "foo", "bool-parameter": True}}, + ], + ) + def test_project_plugs_valid(self, plugs, project_yaml_data): + project = Project.unmarshal(project_yaml_data(plugs=plugs)) + assert project.plugs == plugs + + @pytest.mark.parametrize( + "plugs", + [ + "i am a string", + ["i", "am", "a", "list"], + [{"i": "am"}, {"a": "list"}, {"of": "dictionaries"}], + ], + ) + def test_project_plugs_invalid(self, plugs, project_yaml_data): + error = ".*value is not a valid dict" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(plugs=plugs)) + + def test_project_content_plugs_valid(self, project_yaml_data): + content_plug_data = { + "content-interface": { + "interface": "content", + "target": "test-target", + "content": "test-content", + "default-provider": "test-provider", + } + } + content_plug = ContentPlug(**content_plug_data["content-interface"]) + + project = Project.unmarshal(project_yaml_data(plugs=content_plug_data)) + assert project.plugs is not None + assert project.plugs["content-interface"] == content_plug + + def test_project_content_plugs_missing_target(self, project_yaml_data): + content_plug = { + "content-interface": { + "interface": "content", + "content": "test-content", + "default-provider": "test-provider", + } + } + error = ".*'content-interface' must have a 'target' parameter" + + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(plugs=content_plug)) + + def test_project_get_content_snaps(self, project_yaml_data): + content_plug_data = { + "content-interface": { + "interface": "content", + "target": "test-target", + "content": "test-content", + "default-provider": "test-provider", + } + } + + project = Project.unmarshal(project_yaml_data(plugs=content_plug_data)) + assert project.get_content_snaps() == ["test-provider"] + + @pytest.mark.parametrize("decl_type", ["symlink", "bind", "bind-file", "type"]) + def test_project_layout(self, decl_type, project_yaml_data): + project = Project.unmarshal( + project_yaml_data(layout={"foo": {decl_type: "bar"}}) + ) + assert project.layout is not None + assert project.layout["foo"][decl_type] == "bar" + + def test_project_layout_invalid(self, project_yaml_data): + error = ( + "Bad snapcraft.yaml content:\n" + "- unexpected value; permitted: 'symlink', 'bind', 'bind-file', 'type'" + ) + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(layout={"foo": {"invalid": "bar"}})) + + +class TestHookValidation: + """Validate hooks.""" + + @pytest.mark.parametrize( + "hooks", + [ + {"configure": {}}, + { + "configure": { + "command-chain": ["test-1", "test-2"], + "environment": { + "FIRST_VARIABLE": "test-3", + "SECOND_VARIABLE": "test-4", + }, + "plugs": ["home", "network"], + } + }, + ], + ) + def test_project_hooks_valid(self, hooks, project_yaml_data): + configure_hook_data = Hook(**hooks["configure"]) + project = Project.unmarshal(project_yaml_data(hooks=hooks)) + + assert project.hooks is not None + assert project.hooks["configure"] == configure_hook_data + + def test_project_hooks_command_chain_invalid(self, project_yaml_data): + hook = {"configure": {"command-chain": ["_invalid!"]}} + error = "'_invalid!' is not a valid command chain" + + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(hooks=hook)) + + @pytest.mark.parametrize( + "environment", + [ + "i am a string", + ["i", "am", "a", "list"], + [{"i": "am"}, {"a": "list"}, {"of": "dictionaries"}], + ], + ) + def test_project_hooks_environment_invalid(self, environment, project_yaml_data): + hooks = {"configure": {"environment": environment}} + + error = ".*value is not a valid dict" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(hooks=hooks)) + + def test_project_hooks_plugs_empty(self, project_yaml_data): + hook = {"configure": {"plugs": []}} + error = ".*'plugs' field cannot be empty" + + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(project_yaml_data(hooks=hook)) + + +class TestAppValidation: + """Validate apps.""" + + def test_app_command(self, app_yaml_data): + data = app_yaml_data(command="test-command") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].command == "test-command" + + @pytest.mark.parametrize( + "autostart", + ["myapp.desktop", "_invalid"], + ) + def test_app_autostart(self, autostart, app_yaml_data): + data = app_yaml_data(autostart=autostart) + + if autostart != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].autostart == autostart + else: + error = ".*'_invalid' is not a valid desktop file name" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_common_id(self, app_yaml_data): + data = app_yaml_data(common_id="test-common-id") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].common_id == "test-common-id" + + @pytest.mark.parametrize( + "bus_name", + ["test-bus-name", "_invalid!"], + ) + def test_app_bus_name(self, bus_name, app_yaml_data): + data = app_yaml_data(bus_name=bus_name) + + if bus_name != "_invalid!": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].bus_name == bus_name + else: + error = ".*'_invalid!' is not a valid bus name" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_completer(self, app_yaml_data): + data = app_yaml_data(completer="test-completer") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].completer == "test-completer" + + def test_app_stop_command(self, app_yaml_data): + data = app_yaml_data(stop_command="test-stop-command") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].stop_command == "test-stop-command" + + def test_app_post_stop_command(self, app_yaml_data): + data = app_yaml_data(post_stop_command="test-post-stop-command") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].post_stop_command == "test-post-stop-command" + + @pytest.mark.parametrize( + "start_timeout", ["10", "10ns", "10us", "10ms", "10s", "10m"] + ) + def test_app_start_timeout_valid(self, start_timeout, app_yaml_data): + data = app_yaml_data(start_timeout=start_timeout) + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].start_timeout == start_timeout + + @pytest.mark.parametrize( + "start_timeout", + ["10 s", "10 seconds", "1:00", "invalid"], + ) + def test_app_start_timeout_invalid(self, start_timeout, app_yaml_data): + data = app_yaml_data(start_timeout=start_timeout) + + error = f".*'{start_timeout}' is not a valid time value" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "stop_timeout", ["10", "10ns", "10us", "10ms", "10s", "10m"] + ) + def test_app_stop_timeout_valid(self, stop_timeout, app_yaml_data): + data = app_yaml_data(stop_timeout=stop_timeout) + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].stop_timeout == stop_timeout + + @pytest.mark.parametrize( + "stop_timeout", + ["10 s", "10 seconds", "1:00", "invalid"], + ) + def test_app_stop_timeout_invalid(self, stop_timeout, app_yaml_data): + data = app_yaml_data(stop_timeout=stop_timeout) + + error = f".*'{stop_timeout}' is not a valid time value" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "watchdog_timeout", ["10", "10ns", "10us", "10ms", "10s", "10m"] + ) + def test_app_watchdog_timeout_valid(self, watchdog_timeout, app_yaml_data): + data = app_yaml_data(watchdog_timeout=watchdog_timeout) + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].watchdog_timeout == watchdog_timeout + + @pytest.mark.parametrize( + "watchdog_timeout", + ["10 s", "10 seconds", "1:00", "invalid"], + ) + def test_app_watchdog_timeout_invalid(self, watchdog_timeout, app_yaml_data): + data = app_yaml_data(watchdog_timeout=watchdog_timeout) + + error = f".*'{watchdog_timeout}' is not a valid time value" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_reload_command(self, app_yaml_data): + data = app_yaml_data(reload_command="test-reload-command") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].reload_command == "test-reload-command" + + @pytest.mark.parametrize( + "restart_delay", ["10", "10ns", "10us", "10ms", "10s", "10m"] + ) + def test_app_restart_delay_valid(self, restart_delay, app_yaml_data): + data = app_yaml_data(restart_delay=restart_delay) + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].restart_delay == restart_delay + + @pytest.mark.parametrize( + "restart_delay", + ["10 s", "10 seconds", "1:00", "invalid"], + ) + def test_app_restart_delay_invalid(self, restart_delay, app_yaml_data): + data = app_yaml_data(restart_delay=restart_delay) + + error = f".*'{restart_delay}' is not a valid time value" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_timer(self, app_yaml_data): + data = app_yaml_data(timer="test-timer") + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].timer == "test-timer" + + @pytest.mark.parametrize( + "daemon", + ["simple", "forking", "oneshot", "notify", "dbus", "_invalid"], + ) + def test_app_daemon(self, daemon, app_yaml_data): + data = app_yaml_data(daemon=daemon) + + if daemon != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].daemon == daemon + else: + error = ".*unexpected value; permitted: 'simple', 'forking', 'oneshot'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "after", + [ + "i am a string", + ["i", "am", "a", "list"], + ], + ) + def test_app_after(self, after, app_yaml_data): + data = app_yaml_data(after=after) + + if after == "i am a string": + error = ".*value is not a valid list" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + else: + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].after == after + + def test_app_duplicate_after(self, app_yaml_data): + data = app_yaml_data(after=["duplicate", "duplicate"]) + + error = ".*duplicate entries in 'after' not permitted" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "before", + [ + "i am a string", + ["i", "am", "a", "list"], + ], + ) + def test_app_before(self, before, app_yaml_data): + data = app_yaml_data(before=before) + + if before == "i am a string": + error = ".*value is not a valid list" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + else: + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].before == before + + def test_app_duplicate_before(self, app_yaml_data): + data = app_yaml_data(before=["duplicate", "duplicate"]) + + error = ".*duplicate entries in 'before' not permitted" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("refresh_mode", ["endure", "restart", "_invalid"]) + def test_app_refresh_mode(self, refresh_mode, app_yaml_data): + data = app_yaml_data(refresh_mode=refresh_mode) + + if refresh_mode != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].refresh_mode == refresh_mode + else: + error = ".*unexpected value; permitted: 'endure', 'restart'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "stop_mode", + [ + "sigterm", + "sigterm-all", + "sighup", + "sighup-all", + "sigusr1", + "sigusr1-all", + "sigusr2", + "sigusr2-all", + "_invalid", + ], + ) + def test_app_stop_mode(self, stop_mode, app_yaml_data): + data = app_yaml_data(stop_mode=stop_mode) + + if stop_mode != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].stop_mode == stop_mode + else: + error = ".*unexpected value; permitted: 'sigterm', 'sigterm-all', 'sighup'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "restart_condition", + [ + "on-success", + "on-failure", + "on-abnormal", + "on-abort", + "on-watchdog", + "always", + "never", + "_invalid", + ], + ) + def test_app_restart_condition(self, restart_condition, app_yaml_data): + data = app_yaml_data(restart_condition=restart_condition) + + if restart_condition != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].restart_condition == restart_condition + else: + error = ".*unexpected value; permitted: 'on-success', 'on-failure', 'on-abnormal'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("install_mode", ["enable", "disable", "_invalid"]) + def test_app_install_mode(self, install_mode, app_yaml_data): + data = app_yaml_data(install_mode=install_mode) + + if install_mode != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].install_mode == install_mode + else: + error = ".*unexpected value; permitted: 'enable', 'disable'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_valid_aliases(self, app_yaml_data): + data = app_yaml_data(aliases=["i", "am", "a", "list"]) + + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].aliases == ["i", "am", "a", "list"] + + @pytest.mark.parametrize( + "aliases", + [ + "i am a string", + ["_invalid!"], + ], + ) + def test_app_invalid_aliases(self, aliases, app_yaml_data): + data = app_yaml_data(aliases=aliases) + + if isinstance(aliases, list): + error = f".*'{aliases[0]}' is not a valid alias" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + else: + error = ".*value is not a valid list" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_duplicate_aliases(self, app_yaml_data): + data = app_yaml_data(aliases=["duplicate", "duplicate"]) + + error = ".*duplicate entries in 'aliases' not permitted" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "environment", + [ + {"SINGLE_VARIABLE": "foo"}, + {"FIRST_VARIABLE": "foo", "SECOND_VARIABLE": "bar"}, + ], + ) + def test_app_environment_valid(self, environment, app_yaml_data): + data = app_yaml_data(environment=environment) + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].environment == environment + + @pytest.mark.parametrize( + "environment", + [ + "i am a string", + ["i", "am", "a", "list"], + [{"i": "am"}, {"a": "list"}, {"of": "dictionaries"}], + ], + ) + def test_app_environment_invalid(self, environment, app_yaml_data): + data = app_yaml_data(environment=environment) + + error = ".*value is not a valid dict" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("adapter", ["none", "full", "_invalid"]) + def test_app_adapter(self, adapter, app_yaml_data): + data = app_yaml_data(adapter=adapter) + + if adapter != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].adapter == adapter + else: + error = ".*unexpected value; permitted: 'none', 'full'" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize( + "command_chain", + [ + "i am a string", + ["_invalid!"], + ["snap/command-chain/snapcraft-runner"], + ["i", "am", "a", "list"], + ], + ) + def test_app_command_chain(self, command_chain, app_yaml_data): + data = app_yaml_data(command_chain=command_chain) + + if command_chain == "i am a string": + error = ".*value is not a valid list" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + elif command_chain == ["_invalid!"]: + error = f".*'{command_chain[0]}' is not a valid command chain" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + else: + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].command_chain == command_chain + + @pytest.mark.parametrize("listen_stream", [1, 100, 65535, "/tmp/mysocket.sock"]) + def test_app_sockets_valid_listen_stream(self, listen_stream, socket_yaml_data): + data = socket_yaml_data(listen_stream=listen_stream) + + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].sockets is not None + assert project.apps["app1"].sockets["socket1"].listen_stream == listen_stream + + @pytest.mark.parametrize("listen_stream", [-1, 0, 65536]) + def test_app_sockets_invalid_listen_stream(self, listen_stream, socket_yaml_data): + data = socket_yaml_data(listen_stream=listen_stream) + + error = f".*{listen_stream} is not an integer between 1 and 65535" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + def test_app_sockets_missing_listen_stream(self, socket_yaml_data): + data = socket_yaml_data() + + error = ".*field 'listen-stream' required" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + @pytest.mark.parametrize("socket_mode", [1, "_invalid"]) + def test_app_sockets_valid_socket_mode(self, socket_mode, socket_yaml_data): + data = socket_yaml_data(listen_stream="test", socket_mode=socket_mode) + + if socket_mode != "_invalid": + project = Project.unmarshal(data) + assert project.apps is not None + assert project.apps["app1"].sockets is not None + assert project.apps["app1"].sockets["socket1"].socket_mode == socket_mode + else: + error = ".*value is not a valid integer" + with pytest.raises(errors.ProjectValidationError, match=error): + Project.unmarshal(data) + + +class TestGrammarValidation: + """Basic grammar validation testing.""" + + def test_grammar_trivial(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + } + } + ) + GrammarAwareProject.validate_grammar(data) + + def test_grammar_without_grammar(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + "sources": ".", + "build-environment": [ + {"FOO": "1"}, + {"BAR": "2"}, + ], + "build-packages": ["a", "b"], + "build-snaps": ["d", "e"], + "stage-packages": ["foo", "bar"], + "stage-snaps": ["baz", "quux"], + } + } + ) + GrammarAwareProject.validate_grammar(data) + + def test_grammar_simple(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + "sources": [ + {"on arm64": "this"}, + {"else": "that"}, + ], + "build-environment": [ + { + "on amd64": [ + {"FOO": "1"}, + {"BAR": "2"}, + ] + }, + ], + "build-packages": [{"to arm64,amd64": ["a", "b"]}, "else fail"], + "build-snaps": [ + {"on somearch": ["d", "e"]}, + ], + "stage-packages": [ + "pkg1", + "pkg2", + {"to somearch": ["foo", "bar"]}, + ], + "stage-snaps": [ + {"on arch to otherarch": ["baz", "quux"]}, + ], + } + } + ) + GrammarAwareProject.validate_grammar(data) + + def test_grammar_recursive(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + "sources": [ + {"on arm64": [{"to amd64": "this"}, "else fail"]}, + {"else": "that"}, + ], + } + } + ) + GrammarAwareProject.validate_grammar(data) + + def test_grammar_try(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + "source": [ + {"try": "this"}, + {"else": "that"}, + ], + } + } + ) + + error = r".*- 'try' was removed from grammar, use 'on ' instead" + with pytest.raises(errors.ProjectValidationError, match=error): + GrammarAwareProject.validate_grammar(data) + + def test_grammar_type_error(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + "source": [ + {"on amd64": [25]}, + ], + } + } + ) + + error = r".*- value must be a string: \[25\]" + with pytest.raises(errors.ProjectValidationError, match=error): + GrammarAwareProject.validate_grammar(data) + + def test_grammar_syntax_error(self, project_yaml_data): + data = project_yaml_data( + parts={ + "p1": { + "plugin": "nil", + "source": [ + {"on amd64,,arm64": "foo"}, + ], + } + } + ) + + error = r".*- syntax error in 'on' selector" + with pytest.raises(errors.ProjectValidationError, match=error): + GrammarAwareProject.validate_grammar(data) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000000..9ae6c6a4c9 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,244 @@ +# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- +# +# Copyright 2022 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from textwrap import dedent + +import pytest + +from snapcraft import utils + + +@pytest.mark.parametrize( + "value", + [ + "y", + "Y", + "yes", + "YES", + "Yes", + "t", + "T", + "true", + "TRUE", + "True", + "On", + "ON", + "oN", + "1", + ], +) +def test_strtobool_true(value: str): + assert utils.strtobool(value) is True + + +@pytest.mark.parametrize( + "value", + [ + "n", + "N", + "no", + "NO", + "No", + "f", + "F", + "false", + "FALSE", + "False", + "off", + "OFF", + "oFF", + "0", + ], +) +def test_strtobool_false(value: str): + assert utils.strtobool(value) is False + + +@pytest.mark.parametrize( + "value", + [ + "not", + "yup", + "negative", + "positive", + "whatever", + "2", + "3", + ], +) +def test_strtobool_value_error(value: str): + with pytest.raises(ValueError): + utils.strtobool(value) + + +##################### +# Get Host Platform # +##################### + + +@pytest.mark.parametrize( + "base,build_base,project_type,name,expected_base", + [ + (None, "build_base", "base", "name", "build_base"), + ("base", "build_base", "base", "name", "build_base"), + (None, None, "base", "name", "name"), + ("base", None, "base", "name", "name"), + (None, None, "other", "name", None), + ("base", "build_base", "other", "name", "build_base"), + ("base", None, "other", "name", "base"), + ], +) +def test_get_effective_base(base, build_base, project_type, name, expected_base): + result = utils.get_effective_base( + base=base, build_base=build_base, project_type=project_type, name=name + ) + assert result == expected_base + + +def test_get_os_platform_linux(tmp_path, mocker): + """Utilize an /etc/os-release file to determine platform.""" + # explicitly add commented and empty lines, for parser robustness + filepath = tmp_path / "os-release" + filepath.write_text( + dedent( + """ + # the following is an empty line + + NAME="Ubuntu" + VERSION="20.04.1 LTS (Focal Fossa)" + ID=ubuntu + ID_LIKE=debian + PRETTY_NAME="Ubuntu 20.04.1 LTS" + VERSION_ID="20.04" + HOME_URL="https://www.ubuntu.com/" + SUPPORT_URL="https://help.ubuntu.com/" + BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/" + + # more in the middle; the following even would be "out of standard", but + # we should not crash, just ignore it + SOMETHING-WEIRD + + PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy" + VERSION_CODENAME=focal + UBUNTU_CODENAME=focal + """ + ) + ) + mocker.patch("platform.machine", return_value="x86_64") + mocker.patch("platform.system", return_value="Linux") + + os_platform = utils.get_os_platform(filepath) + + assert os_platform.system == "ubuntu" + assert os_platform.release == "20.04" + assert os_platform.machine == "x86_64" + + +@pytest.mark.parametrize( + "name", + [ + ('"foo bar"', "foo bar"), # what's normally found + ("foo bar", "foo bar"), # no quotes + ('"foo " bar"', 'foo " bar'), # quotes in the middle + ('foo bar"', 'foo bar"'), # unbalanced quotes (no really enclosing) + ('"foo bar', '"foo bar'), # unbalanced quotes (no really enclosing) + ("'foo bar'", "foo bar"), # enclosing with single quote + ("'foo ' bar'", "foo ' bar"), # single quote in the middle + ("foo bar'", "foo bar'"), # unbalanced single quotes (no really enclosing) + ("'foo bar", "'foo bar"), # unbalanced single quotes (no really enclosing) + ("'foo bar\"", "'foo bar\""), # unbalanced mixed quotes + ("\"foo bar'", "\"foo bar'"), # unbalanced mixed quotes + ], +) +def test_get_os_platform_alternative_formats(tmp_path, mocker, name): + """Support different ways of building the string.""" + source, result = name + filepath = tmp_path / "os-release" + filepath.write_text( + dedent( + f""" + ID={source} + VERSION_ID="20.04" + """ + ) + ) + # need to patch this to "Linux" so actually uses /etc/os-release... + mocker.patch("platform.system", return_value="Linux") + + os_platform = utils.get_os_platform(filepath) + + assert os_platform.system == result + + +def test_get_os_platform_windows(mocker): + """Get platform from a patched Windows machine.""" + mocker.patch("platform.system", return_value="Windows") + mocker.patch("platform.release", return_value="10") + mocker.patch("platform.machine", return_value="AMD64") + + os_platform = utils.get_os_platform() + + assert os_platform.system == "Windows" + assert os_platform.release == "10" + assert os_platform.machine == "AMD64" + + +@pytest.mark.parametrize( + "platform_machine,platform_architecture,deb_arch", + [ + ("AMD64", ("64bit", "ELF"), "amd64"), + ("aarch64", ("64bit", "ELF"), "arm64"), + ("aarch64", ("32bit", "ELF"), "armhf"), + ("armv7l", ("64bit", "ELF"), "armhf"), + ("ppc", ("64bit", "ELF"), "powerpc"), + ("ppc64le", ("64bit", "ELF"), "ppc64el"), + ("x86_64", ("64bit", "ELF"), "amd64"), + ("x86_64", ("32bit", "ELF"), "i386"), + ("unknown-arch", ("64bit", "ELF"), "unknown-arch"), + ], +) +def test_get_host_architecture( + platform_machine, platform_architecture, mocker, deb_arch +): + """Test all platform mappings in addition to unknown.""" + mocker.patch("platform.machine", return_value=platform_machine) + mocker.patch("platform.architecture", return_value=platform_architecture) + + assert utils.get_host_architecture() == deb_arch + + +################# +# Humanize List # +################# + + +@pytest.mark.parametrize( + "items,conjunction,expected", + ( + ([], "and", ""), + (["foo"], "and", "'foo'"), + (["foo", "bar"], "and", "'bar' and 'foo'"), + (["foo", "bar", "baz"], "and", "'bar', 'baz', and 'foo'"), + (["foo", "bar", "baz", "qux"], "and", "'bar', 'baz', 'foo', and 'qux'"), + ([], "or", ""), + (["foo"], "or", "'foo'"), + (["foo", "bar"], "or", "'bar' or 'foo'"), + (["foo", "bar", "baz"], "or", "'bar', 'baz', or 'foo'"), + (["foo", "bar", "baz", "qux"], "or", "'bar', 'baz', 'foo', or 'qux'"), + ), +) +def test_humanize_list(items, conjunction, expected): + assert utils.humanize_list(items, conjunction) == expected diff --git a/tools/brew_install_from_source.py b/tools/brew_install_from_source.py index 644d457d19..22ca41d371 100755 --- a/tools/brew_install_from_source.py +++ b/tools/brew_install_from_source.py @@ -28,11 +28,11 @@ def main(): temp_dir = tempfile.mkdtemp() compressed_snapcraft_source = download_snapcraft_source(temp_dir) compressed_snapcraft_sha256 = sha256_checksum(compressed_snapcraft_source) - brew_formula_path = os.path.join(temp_dir, "snapcraft.rb") + brew_formula_path = os.path.join(temp_dir, "snapcraft_legacy.rb") download_brew_formula(brew_formula_path) patched_dir = os.path.join(temp_dir, "patched") os.mkdir(patched_dir) - brew_formula_from_source_path = os.path.join(patched_dir, "snapcraft.rb") + brew_formula_from_source_path = os.path.join(patched_dir, "snapcraft_legacy.rb") patch_brew_formula_source( brew_formula_path, brew_formula_from_source_path, diff --git a/tools/freeze-requirements.sh b/tools/freeze-requirements.sh index b88d7d6cb0..5f0279c9af 100755 --- a/tools/freeze-requirements.sh +++ b/tools/freeze-requirements.sh @@ -5,8 +5,7 @@ requirements_fixups() { # Python apt library pinned to source. sed -i '/python-apt=*/d' "$req_file" - echo 'python-apt @ http://archive.ubuntu.com/ubuntu/pool/main/p/python-apt/python-apt_1.6.5ubuntu0.5.tar.xz; sys.platform == "linux"' >> "$req_file" - echo 'python-distutils-extra @ https://launchpad.net/python-distutils-extra/trunk/2.39/+download/python-distutils-extra-2.39.tar.gz; sys_platform == "linux"' >> "$req_file" + echo 'python-apt @ https://launchpad.net/ubuntu/+archive/primary/+sourcefiles/python-apt/2.0.0ubuntu0.20.04.6/python-apt_2.0.0ubuntu0.20.04.6.tar.xz; sys.platform == "linux"' >> "$req_file" # PyNaCl 1.4.0 has crypto related symbol issues when using the system # provided sodium. Ensure it is compiled on linux by pointing to source. @@ -15,10 +14,10 @@ requirements_fixups() { echo 'PyNaCl @ https://files.pythonhosted.org/packages/61/ab/2ac6dea8489fa713e2b4c6c5b549cc962dd4a842b5998d9e80cf8440b7cd/PyNaCl-1.3.0.tar.gz; sys.platform == "linux"' >> "$req_file" # https://bugs.launchpad.net/ubuntu/+source/python-pip/+bug/1635463 - sed -i '/pkg-resources==0.0.0/d' "$req_file" + sed -i '/pkg[-_]resources==0.0.0/d' "$req_file" # We updated setuptools in venv, forget it. - sed -i '/setuptools/d' "$req_file" + sed -i '/^setuptools/d' "$req_file" echo 'setuptools==49.6.0' >> "$req_file" # Pinned pyinstaller for windows. diff --git a/units.py b/units.py index c3b19f15f1..16351b2fa9 100644 --- a/units.py +++ b/units.py @@ -1,5 +1,5 @@ import unittest unittest.main( - "snapcraft.tests.unit.commands.test_build", argv=["BuildCommandTestCase"] + "snapcraft_legacy.tests.unit.commands.test_build", argv=["BuildCommandTestCase"] ) # noqa