From b8b5c56133ad3d245c4cdfad0ee7a8fe1ce80acb Mon Sep 17 00:00:00 2001 From: Sheng Yu Date: Wed, 26 Jul 2023 13:17:35 -0400 Subject: [PATCH] rebase: using starbase --- .editorconfig | 43 +++ .github/PULL_REQUEST_TEMPLATE.md | 2 + .github/release-drafter.yml | 2 - .github/renovate.json5 | 101 +++++++ .github/workflows/cla-check.yaml | 2 +- .github/workflows/docs.yaml | 37 +++ ...lease-drafter.yml => release-drafter.yaml} | 3 +- .github/workflows/release-publish.yaml | 60 +++++ .github/workflows/tests.yaml | 151 +++++++---- .gitignore | 51 +++- .pre-commit-config.yaml | 30 +++ .readthedocs.yml | 19 +- .yamllint.yaml | 12 + Makefile | 109 -------- README.md | 21 +- craft_store/auth.py | 34 ++- craft_store/base_client.py | 30 ++- craft_store/creds.py | 4 +- craft_store/endpoints.py | 8 +- craft_store/errors.py | 19 +- craft_store/http_client.py | 8 +- craft_store/models/_base_model.py | 2 +- craft_store/models/revisions_model.py | 2 +- craft_store/models/track_guardrail_model.py | 4 +- craft_store/models/track_model.py | 2 +- craft_store/store_client.py | 24 +- craft_store/ubuntu_one_store_client.py | 21 +- docs/.gitignore | 3 - docs/Makefile | 20 -- docs/_static/css/custom.css | 28 ++ docs/changelog.rst | 8 +- docs/conf.py | 118 +++----- docs/explanation/index.rst | 7 + docs/{howtos.rst => howto/index.rst} | 15 +- docs/index.rst | 18 +- docs/reference/index.rst | 13 + docs/requirements.txt | 10 - docs/{tutorials.rst => tutorials/index.rst} | 24 +- pyproject.toml | 254 +++++++++++++++++- setup.py | 24 -- tests/integration/conftest.py | 13 +- tests/integration/test_auth.py | 9 +- tests/integration/test_get_list_releases.py | 1 - tests/integration/test_register_unregister.py | 1 - tests/integration/test_release.py | 1 - tests/integration/test_upload.py | 1 - tests/unit/conftest.py | 2 +- .../unit/models/__init__.py | 0 tests/unit/models/test_account_model.py | 6 +- .../models/test_charm_list_releases_model.py | 3 +- .../unit/models/test_registered_name_model.py | 3 +- .../models/test_snap_list_releases_model.py | 3 +- .../unit/models/test_track_guardrail_model.py | 5 +- tests/unit/models/test_track_model.py | 1 - tests/unit/test_auth.py | 17 +- tests/unit/test_base_client.py | 15 +- tests/unit/test_creds.py | 5 +- tests/unit/test_endpoints.py | 5 +- tests/unit/test_errors.py | 14 +- tests/unit/test_http_client.py | 31 ++- tests/unit/test_store_client.py | 71 ++--- tests/unit/test_ubuntu_one_store_client.py | 29 +- tools/freeze-requirements.sh | 14 - tox.ini | 162 +++++++++-- 64 files changed, 1154 insertions(+), 601 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/renovate.json5 create mode 100644 .github/workflows/docs.yaml rename .github/workflows/{release-drafter.yml => release-drafter.yaml} (65%) create mode 100644 .github/workflows/release-publish.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .yamllint.yaml delete mode 100644 Makefile delete mode 100644 docs/.gitignore delete mode 100644 docs/Makefile create mode 100644 docs/_static/css/custom.css create mode 100644 docs/explanation/index.rst rename docs/{howtos.rst => howto/index.rst} (94%) create mode 100644 docs/reference/index.rst delete mode 100644 docs/requirements.txt rename docs/{tutorials.rst => tutorials/index.rst} (93%) delete mode 100644 setup.py rename docs/_static/.gitempty => tests/unit/models/__init__.py (100%) delete mode 100755 tools/freeze-requirements.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6056a3e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,43 @@ +# Editor configuration options. +# See: https://spec.editorconfig.org/ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 80 +trim_trailing_whitespace = true + +[.editorconfig] +max_line_length = off + +[Makefile] +indent_style = tab + +[{*.py,*.pyi}] +max_line_length = 88 + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 + +[{*.har,*.json,*.json5}] +indent_size = 2 +max_line_length = off + +[{*.markdown,*.md,*.rst}] +max_line_length = off +ij_visual_guides = none + +[{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock}] +max_line_length = off + +[{*.ini, *.cfg}] +max_line_length = off + +[{*.yaml,*.yml}] +indent_size = 2 +max_line_length = off diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 440f2ad..da2c973 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,5 @@ +- [ ] Have you followed the guidelines for contributing? - [ ] Have you signed the [CLA](http://www.ubuntu.com/legal/contributors/)? +- [ ] Have you successfully run `tox`? ----- diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 472a2a0..5262e23 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -15,9 +15,7 @@ categories: - title: "Tooling" label: - "tooling" - change-template: '- $TITLE @$AUTHOR (#$NUMBER)' - template: | Special thanks to the contributors that made this release happen: $CONTRIBUTORS diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..09d90fa --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,101 @@ +{ + // Configuration file for RenovateBot: https://docs.renovatebot.com/configuration-options + extends: ["config:base"], + labels: ["dependencies"], // For convenient searching in GitHub + pip_requirements: { + fileMatch: ["^tox.ini$", "(^|/)requirements([\\w-]*)\\.txt$"] + }, + packageRules: [ + { + // Automerge patches, pin changes and digest changes. + // Also groups these changes together. + groupName: "bugfixes", + excludePackagePrefixes: ["dev", "lint", "types"], + matchUpdateTypes: ["patch", "pin", "digest"], + prPriority: 3, // Patches should go first! + automerge: true + }, + { + // Update all internal packages in one higher-priority PR + groupName: "internal packages", + matchPackagePrefixes: ["craft-", "snap-"], + matchLanguages: ["python"], + prPriority: 2 + }, + { + // GitHub Actions are higher priority to update than most dependencies. + groupName: "GitHub Actions", + matchManagers: ["github-actions"], + prPriority: 1, + automerge: true, + }, + // Everything not in one of these rules gets priority 0 and falls here. + { + // Minor changes can be grouped and automerged for dev dependencies, but are also deprioritised. + groupName: "development dependencies (non-major)", + groupSlug: "dev-dependencies", + matchPackagePrefixes: [ + "dev", + "lint", + "types" + ], + excludePackagePatterns: ["ruff"], + matchUpdateTypes: ["minor", "patch", "pin", "digest"], + prPriority: -1, + automerge: true + }, + { + // Documentation related updates + groupName: "documentation dependencies", + groupSlug: "doc-dependencies", + matchPackageNames: ["Sphinx"], + matchPackagePatterns: ["^[Ss]phinx.*$", "^furo$"], + matchPackagePrefixes: ["docs"], + }, + { + // Other major dependencies get deprioritised below minor dev dependencies. + matchUpdateTypes: ["major"], + prPriority: -2 + }, + { + // Major dev dependencies are stone last, but grouped. + groupName: "development dependencies (major versions)", + groupSlug: "dev-dependencies", + matchDepTypes: ["devDependencies"], + matchUpdateTypes: ["major"], + prPriority: -3 + }, + { + // Ruff is still unstable, so update it separately. + groupName: "ruff", + matchPackagePatterns: ["^(lint/)?ruff$"], + prPriority: -3 + } + ], + regexManagers: [ + { + // tox.ini can get updates too if we specify for each package. + fileMatch: ["tox.ini"], + depTypeTemplate: "devDependencies", + matchStrings: [ + "# renovate: datasource=(?\\S+)\n\\s+(?.*?)(\\[[\\w]*\\])*[=><]=?(?.*?)\n" + ] + }, + { + // .pre-commit-config.yaml version updates + fileMatch: [".pre-commit-config.yaml"], + depTypeTemplate: "devDependencies", + matchStrings: [ + "# renovate: datasource=(?\\S+);\\s*depName=(?.*?)\n\s+rev: \"v?(?.*?)\"" + ] + } + ], + timezone: "Etc/UTC", + automergeSchedule: ["every weekend"], + schedule: ["every weekend"], + prConcurrentLimit: 2, // No more than 2 open PRs at a time. + prCreation: "not-pending", // Wait until status checks have completed before raising the PR + prNotPendingHours: 4, // ...unless the status checks have been running for 4+ hours. + prHourlyLimit: 1, // No more than 1 PR per hour. + stabilityDays: 2 // Wait 2 days from release before updating. +} diff --git a/.github/workflows/cla-check.yaml b/.github/workflows/cla-check.yaml index 612d89a..cdb271a 100644 --- a/.github/workflows/cla-check.yaml +++ b/.github/workflows/cla-check.yaml @@ -3,7 +3,7 @@ on: [pull_request] jobs: cla-check: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check if CLA signed uses: canonical/has-signed-canonical-cla@v1 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..4026a0d --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,37 @@ +name: Documentation +on: + push: + branches: + - "main" + - "feature/*" + - "hotfix/*" + - "release/*" + pull_request: + paths: + - "docs/**" + - "pyproject.toml" + - ".github/workflows/docs.yaml" + +jobs: + sphinx: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install Tox + run: pip install tox + - name: Lint documentation + run: tox run -e lint-docs + - name: Build documentation + run: tox run -e build-docs + - name: Upload documentation + uses: actions/upload-artifact@v3 + with: + name: docs + path: docs/_build/ diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yaml similarity index 65% rename from .github/workflows/release-drafter.yml rename to .github/workflows/release-drafter.yaml index cf1f1f9..e60ebc1 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yaml @@ -2,6 +2,7 @@ name: Release Drafter on: push: + # branches to consider in the event; optional, defaults to all branches: - main @@ -10,6 +11,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Release Drafter - uses: release-drafter/release-drafter@v5.7.0 + uses: release-drafter/release-drafter@v5.23.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-publish.yaml b/.github/workflows/release-publish.yaml new file mode 100644 index 0000000..4ee72f5 --- /dev/null +++ b/.github/workflows/release-publish.yaml @@ -0,0 +1,60 @@ +name: Release +on: + push: + tags: + # These tags should be protected, remember to enable the rule: + # https://github.com/canonical/starbase/settings/tag_protection + - "[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + source-wheel: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Fetch tag annotations + run: | + git fetch --force --tags --depth 1 + git describe --dirty --long --match '[0-9]*.[0-9]*.[0-9]*' --exclude '*[^0-9.]*' + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + check-latest: true + - name: Build packages + run: | + pip install build twine + python3 -m build + twine check dist/* + - name: Upload pypi packages artifact + uses: actions/upload-artifact@v3 + with: + name: pypi-packages + path: dist/ + pypi: + needs: ["source-wheel"] + runs-on: ubuntu-latest + steps: + - name: Get packages + uses: actions/download-artifact@v3 + with: + name: pypi-packages + path: dist/ + - name: Publish to pypi + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + github-release: + needs: ["source-wheel"] + runs-on: ubuntu-latest + steps: + - name: Get pypi artifacts + uses: actions/download-artifact@v3 + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: | + ** diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d64cfad..94f0e58 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,71 +1,118 @@ -name: Tests - +name: test on: - pull_request: push: branches: - - main + - "main" + - "feature/*" + - "hotfix/*" + - "release/*" + pull_request: jobs: - linters: - runs-on: ubuntu-20.04 + lint: + runs-on: ubuntu-latest steps: - - name: Checkout code + - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Install python packages and dependencies - run: | - pip install -U .[dev] - - name: Run black - run: | - make test-black - - name: Run codespell - run: | - make test-codespell - - name: Run flake8 - run: | - make test-flake8 - - name: Run isort - run: | - make test-isort - - name: Run mypy - run: | - make test-mypy - - name: Run pydocstyle - run: | - make test-pydocstyle - - name: Run pyright + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + cache: 'pip' + - name: Configure environment run: | - sudo snap install --classic node - sudo snap install --classic pyright - make test-pyright - - tests: + echo "::group::Begin snap install" + echo "Installing snaps in the background while running apt and pip..." + sudo snap install --no-wait --classic pyright + sudo snap install --no-wait shellcheck + echo "::endgroup::" + echo "::group::pip install" + python -m pip install tox + echo "::endgroup::" + echo "::group::Create virtual environments for linting processes." + tox run -m lint --notest + echo "::endgroup::" + echo "::group::Wait for snap to complete" + snap watch --last=install + echo "::endgroup::" + - name: Run Linters + run: tox run --skip-pkg-install --no-list-dependencies -m lint + unit: strategy: matrix: - os: [macos-12, ubuntu-20.04, ubuntu-22.04, windows-2019] - python-version: ["3.8", "3.9", "3.10"] - - runs-on: ${{ matrix.os }} + platform: [ubuntu-20.04, ubuntu-22.04, windows-latest, macos-latest] + runs-on: ${{ matrix.platform }} steps: - - name: Checkout code - uses: actions/checkout@v3 + - uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }} - uses: actions/setup-python@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -U .[dev] - pip install -e . - - name: Run unit tests + python-version: | + 3.8 + 3.10 + 3.11 + 3.12-dev + cache: 'pip' + - name: Configure environment run: | - make test-units - - name: Run integration tests + echo "::group::pip install" + python -m pip install tox + echo "::endgroup::" + mkdir -p results + - name: Setup Tox environments + run: tox run -m tests --notest + - name: Test with tox + run: tox run --skip-pkg-install --no-list-dependencies --result-json results/tox-${{ matrix.platform }}.json -m unit-tests env: - CRAFT_STORE_CHARMCRAFT_CREDENTIALS: ${{ secrets.CRAFT_STORE_CHARMCRAFT_CREDENTIALS }} + PYTEST_ADDOPTS: "--no-header -vv -rN" + - name: Upload code coverage + uses: codecov/codecov-action@v3 + with: + directory: ./results/ + files: coverage*.xml + - name: Upload test results + if: success() || failure() + uses: actions/upload-artifact@v3 + with: + name: test-results-${{ matrix.platform }} + path: results/ + integration: + strategy: + matrix: + platform: [ubuntu-20.04, ubuntu-22.04, windows-latest, macos-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: | + 3.8 + 3.10 + 3.11 + 3.12-dev + cache: 'pip' + - name: Configure environment run: | - make test-integrations + echo "::group::pip install" + python -m pip install tox + echo "::endgroup::" + mkdir -p results + - name: Setup Tox environments + run: tox run -m tests --notest + - name: Test with tox + run: tox run --skip-pkg-install --no-list-dependencies --result-json results/tox-${{ matrix.platform }}.json -m integration-tests + env: + PYTEST_ADDOPTS: "--no-header -vv -rN" + - name: Upload test results + if: success() || failure() + uses: actions/upload-artifact@v3 + with: + name: test-results-${{ matrix.platform }} + path: results/ diff --git a/.gitignore b/.gitignore index b4b993e..4cb5044 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ __pycache__/ # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -21,9 +20,12 @@ parts/ sdist/ var/ wheels/ +pip-wheel-metadata/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller # Usually these files are written by a python script from a template @@ -38,12 +40,14 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover +*.py,cover .hypothesis/ .pytest_cache/ @@ -54,6 +58,8 @@ coverage.xml # Django stuff: *.log local_settings.py +db.sqlite3 +db.sqlite3-journal # Flask stuff: instance/ @@ -71,22 +77,38 @@ target/ # Jupyter Notebook .ipynb_checkpoints +# IPython +profile_default/ +ipython_config.py + # pyenv .python-version -# celery beat schedule file +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid # SageMath parsed files *.sage.py -# dotenv +# Environments .env - -# virtualenv .venv +env/ venv/ ENV/ +env.bak/ +venv.bak/ # Spyder project settings .spyderproject @@ -100,10 +122,23 @@ ENV/ # mypy .mypy_cache/ +.dmypy.json +dmypy.json -# IDE settings -.vscode/ +# Pyre type checker +.pyre/ + +# Caches for various tools +/.*_cache/ + +# Test results +/results/ # direnv -.direnv .envrc + +# Ignore version module generated by setuptools_scm +/*/_version.py + +# Visual Studio Code +.vscode/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5a9b3a4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: check-toml + - id: fix-byte-order-marker + - id: mixed-line-ending + - repo: https://github.com/charliermarsh/ruff-pre-commit + # renovate: datasource=pypi;depName=ruff + rev: "v0.0.267" + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - repo: https://github.com/psf/black + # renovate: datasource=pypi;depName=black + rev: "23.3.0" + hooks: + - id: black + - repo: https://github.com/adrienverge/yamllint.git + # renovate: datasource=pypi;depName=yamllint + rev: "v1.31.0" + hooks: + - id: yamllint diff --git a/.readthedocs.yml b/.readthedocs.yml index efed0d7..fb58117 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,22 +1,27 @@ -# .readthedocs.yml +# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +# Required version: 2 +# Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py -build: - os: "ubuntu-20.04" - tools: - python: "3.8" - +# Optionally build your docs in additional formats such as PDF formats: - pdf + - epub + +build: + os: ubuntu-22.04 + tools: + python: "3" python: install: - - requirements: docs/requirements.txt - method: pip path: . + extra_requirements: + - docs diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..8f7da74 --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,12 @@ +--- +ignore-from-file: [.gitignore] + +extends: default + +rules: + document-start: disable + float-values: enable + line-length: disable + octal-values: enable + truthy: + check-keys: false diff --git a/Makefile b/Makefile deleted file mode 100644 index d74b97f..0000000 --- a/Makefile +++ /dev/null @@ -1,109 +0,0 @@ -.PHONY: help -help: ## Show this help. - @printf "%-40s %s\n" "Target" "Description" - @printf "%-40s %s\n" "------" "-----------" - @fgrep " ## " $(MAKEFILE_LIST) | fgrep -v grep | awk -F ': .*## ' '{$$1 = sprintf("%-40s", $$1)} 1' - -.PHONY: autoformat -autoformat: ## Run automatic code formatters. - isort . - autoflake --remove-all-unused-imports --ignore-init-module-imports -ri . - black . - -.PHONY: clean -clean: ## Clean artifacts from building, testing, etc. - rm -rf build/ - rm -rf dist/ - rm -rf .eggs/ - find . -name '*.egg-info' -exec rm -rf {} + - find . -name '*.egg' -exec rm -f {} + - rm -rf docs/_build/ - rm -f docs/craft_store.* - rm -f docs/modules.rst - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - find . -name '__pycache__' -exec rm -rf {} + - rm -rf .tox/ - rm -f .coverage - rm -rf htmlcov/ - rm -rf .pytest_cache - -.PHONY: coverage -coverage: ## Run pytest with coverage report. - coverage run --source craft_sore -m pytest - coverage report -m - coverage html - -.PHONY: docs -docs: ## Generate documentation. - rm -f docs/craft_store.rst - rm -f docs/modules.rst - pip install -r docs/requirements.txt - $(MAKE) -C docs clean - $(MAKE) -C docs html - -.PHONY: dist -dist: clean ## Build python package. - python setup.py sdist - python setup.py bdist_wheel - ls -l dist - -.PHONY: freeze-requirements -freeze-requirements: ## Re-freeze requirements. - tools/freeze-requirements.sh - -.PHONY: install -install: clean ## Install python package. - python setup.py install - -.PHONY: lint -lint: test-black test-codespell test-flake8 test-isort test-mypy test-pydocstyle test-pylint test-pyright ## Run all linting tests. - -.PHONY: release -release: dist ## Release with twine. - twine upload dist/* - -.PHONY: test-black -test-black: - black --check --diff . - -.PHONY: test-codespell -test-codespell: - codespell . - -.PHONY: test-flake8 -test-flake8: - flake8 . - -.PHONY: test-integrations -test-integrations: ## Run integration tests. - pytest tests/integration - -.PHONY: test-isort -test-isort: - isort --check craft_store tests - -.PHONY: test-mypy -test-mypy: - mypy craft_store tests - -.PHONY: test-pydocstyle -test-pydocstyle: - pydocstyle craft_store --ignore-decorator=overrides - -.PHONY: test-pylint -test-pylint: - pylint craft_store - pylint tests --disable=missing-module-docstring,missing-function-docstring,redefined-outer-name,line-too-long,duplicate-code - -.PHONY: test-pyright -test-pyright: - pyright . - -.PHONY: test-units -test-units: ## Run unit tests. - pytest tests/unit - -.PHONY: tests -tests: lint test-integrations test-units ## Run all tests. diff --git a/README.md b/README.md index b9aa21d..fd6c696 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,13 @@ https://craft-store.readthedocs.io. # Contributing -A `Makefile` is provided for easy interaction with the project. To see -all available options run: - -``` -make help -``` ## Running tests To run all tests in the suite run: ``` -make tests +tox ``` ### Integration tests @@ -35,7 +29,7 @@ charm package on the staging craft-store. These can be run by creating a pull re Other integration tests simply require a valid login to the staging charmhub store. These can be run by exporting charmhub staging credentials to the environment -variable `CRAFT_STORE_CHARMCRAFT_CREDENTIALS`. An easy way to do this is to +variable `CRAFT_STORE_CHARMCRAFT_CREDENTIALS`. An easy way to do this is to create a `charmcraft.yaml` file containing the lines: charmhub: @@ -51,17 +45,15 @@ on `craft-store-test-charm`, some tests will fail rather than being skipped. If a new dependency is added to the project run: -``` -make freeze-requirements -``` +TODO + ## Verifying documentation changes To locally verify documentation changes run: -``` -make docs -``` +`tox run -e lint-docs,build-docs` + After running, newly generated documentation shall be available at `./docs/_build/html/`. @@ -86,4 +78,3 @@ As an example: Required in order to obtain credentials that apply only to a given package; be it charm, snap or bundle. - diff --git a/craft_store/auth.py b/craft_store/auth.py index 7ddc669..deda2f0 100644 --- a/craft_store/auth.py +++ b/craft_store/auth.py @@ -20,12 +20,13 @@ import binascii import logging import os -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, Union import keyring import keyring.backend import keyring.backends.fail import keyring.errors +from keyring._compat import properties from . import errors @@ -35,8 +36,14 @@ class MemoryKeyring(keyring.backend.KeyringBackend): """A keyring that stores credentials in a dictionary.""" - # Only > 0 make it to the chainer. - priority = -1 # type: ignore + @properties.classproperty # type: ignore[misc] + def priority(self) -> Union[int, float]: + """Supply a priority. + + Indicating the priority of the backend relative to all other backends. + """ + # Only > 0 make it to the chainer. + return -1 def __init__(self) -> None: super().__init__() @@ -56,7 +63,7 @@ def delete_password(self, service: str, username: str) -> None: try: del self._credentials[service, username] except KeyError as key_error: - raise keyring.errors.PasswordDeleteError() from key_error + raise keyring.errors.PasswordDeleteError from key_error class Auth: @@ -79,8 +86,8 @@ def __init__( self, application_name: str, host: str, - environment_auth: Optional[str] = None, ephemeral: bool = False, + environment_auth: Optional[str] = None, ) -> None: """Initialize Auth. @@ -102,7 +109,7 @@ def __init__( self._keyring = keyring.get_keyring() # This keyring would fail on first use, fail early instead. if isinstance(self._keyring, keyring.backends.fail.Keyring): - raise errors.NoKeyringError() + raise errors.NoKeyringError if environment_auth_value: self.set_credentials(self.decode_credentials(environment_auth_value)) @@ -130,12 +137,12 @@ def ensure_no_credentials(self) -> None: :raises errors.KeyringUnlockError: if the keyring cannot be unlocked. """ try: - if self._keyring.get_password(self.application_name, self.host) is not None: - raise errors.CredentialsAlreadyAvailable( - self.application_name, self.host - ) + password = self._keyring.get_password(self.application_name, self.host) except keyring.errors.KeyringLocked as exc: - raise errors.KeyringUnlockError() from exc + raise errors.KeyringUnlockError from exc + + if password is not None: + raise errors.CredentialsAlreadyAvailable(self.application_name, self.host) def set_credentials(self, credentials: str, force: bool = False) -> None: """Store credentials in the keyring. @@ -170,7 +177,7 @@ def get_credentials(self) -> str: encoded_credentials_string = self._keyring.get_password( self.application_name, self.host ) - except Exception as unknown_error: + except Exception as unknown_error: # noqa: BLE001 logger.debug( "Unhandled exception raised when retrieving credentials: %r", unknown_error, @@ -182,8 +189,7 @@ def get_credentials(self) -> str: if encoded_credentials_string is None: logger.debug("Credentials not found in the keyring %r", self._keyring.name) raise errors.CredentialsUnavailable(self.application_name, self.host) - credentials = self.decode_credentials(encoded_credentials_string) - return credentials + return self.decode_credentials(encoded_credentials_string) def del_credentials(self) -> None: """Delete credentials from the keyring.""" diff --git a/craft_store/base_client.py b/craft_store/base_client.py index 8a1e1e5..6a751a8 100644 --- a/craft_store/base_client.py +++ b/craft_store/base_client.py @@ -19,11 +19,14 @@ import logging from abc import ABCMeta, abstractmethod from pathlib import Path -from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, cast +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence from urllib.parse import urlparse import requests -from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor # type: ignore +from requests_toolbelt import ( # type: ignore[import] + MultipartEncoder, + MultipartEncoderMonitor, +) from . import endpoints, errors, models from .auth import Auth @@ -72,7 +75,9 @@ def __init__( ) @abstractmethod - def _get_discharged_macaroon(self, root_macaroon: str, **kwargs) -> str: + def _get_discharged_macaroon( # type: ignore[no-untyped-def] + self, root_macaroon: str, **kwargs + ) -> str: """Return a discharged macaroon ready to use in an Authorization header.""" @abstractmethod @@ -87,9 +92,9 @@ def _get_macaroon(self, token_request: Dict[str, Any]) -> str: json=token_request, ) - return token_response.json()["macaroon"] + return str(token_response.json()["macaroon"]) - def login( + def login( # type: ignore[no-untyped-def] self, *, permissions: Sequence[str], @@ -145,7 +150,7 @@ def login( return self._auth.encode_credentials(store_authorized_macaroon) - def request( + def request( # type: ignore[no-untyped-def] self, method: str, url: str, @@ -181,7 +186,7 @@ def request( def whoami(self) -> Dict[str, Any]: """Return whoami json data queyring :attr:`.endpoints.Endpoints.whoami`.""" - return self.request("GET", self._base_url + self._endpoints.whoami).json() + return dict(self.request("GET", self._base_url + self._endpoints.whoami).json()) def logout(self) -> None: """Clear credentials. @@ -194,7 +199,7 @@ def upload_file( self, *, filepath: Path, - monitor_callback: Optional[Callable] = None, + monitor_callback: Optional[Callable] = None, # type: ignore[type-arg] ) -> str: """Upload filepath to storage. @@ -277,10 +282,7 @@ def notify_revision( "POST", self._base_url + endpoint, json=revision_request.marshal() ).json() - return cast( - models.revisions_model.RevisionsResponseModel, - models.revisions_model.RevisionsResponseModel.unmarshal(response), - ) + return models.revisions_model.RevisionsResponseModel.unmarshal(response) def get_list_releases(self, *, name: str) -> models.MarshableModel: """Query the list_releases endpoint and return the result.""" @@ -353,7 +355,7 @@ def register_name( request_json["type"] = entity_type response = self.request("POST", self._base_url + endpoint, json=request_json) - return response.json()["id"] + return str(response.json()["id"]) def unregister_name(self, name: str) -> str: """Unregister a name with no published packages. @@ -365,4 +367,4 @@ def unregister_name(self, name: str) -> str: endpoint = f"/v1/{self._endpoints.namespace}/{name}" response = self.request("DELETE", self._base_url + endpoint) - return response.json()["package-id"] + return str(response.json()["package-id"]) diff --git a/craft_store/creds.py b/craft_store/creds.py index eecbbee..ac7ad4a 100644 --- a/craft_store/creds.py +++ b/craft_store/creds.py @@ -50,7 +50,7 @@ def marshal_candid_credentials(candid_creds: str) -> str: :param candid_creds: The actual Candid credentials. :return: A payload string ready to be passed to Auth.set_credentials() """ - model = CandidModel(v=candid_creds) # type: ignore + model = CandidModel(v=candid_creds) # type: ignore[call-arg] return json.dumps(model.marshal()) @@ -134,7 +134,7 @@ def marshal_u1_credentials(u1_creds: UbuntuOneMacaroons) -> str: :param u1_creds: The actual Ubuntu One macaroons credentials. :return: A payload string ready to be passed to Auth.set_credentials() """ - model = UbuntuOneModel(v=u1_creds) # type: ignore + model = UbuntuOneModel(v=u1_creds) # type: ignore[call-arg] return json.dumps(model.marshal()) diff --git a/craft_store/endpoints.py b/craft_store/endpoints.py index 1da4861..15a8bc5 100644 --- a/craft_store/endpoints.py +++ b/craft_store/endpoints.py @@ -111,7 +111,7 @@ def get_upload_id(result: Dict[str, Any]) -> str: :param result: the result from an upload request. """ - return result["upload_id"] + return str(result["upload_id"]) def get_releases_endpoint(self, name: str) -> str: """Return the slug to the releases endpoint.""" @@ -162,15 +162,15 @@ def get_token_request( @staticmethod def get_upload_id(result: Dict[str, Any]) -> str: - return result["upload_id"] + return str(result["upload_id"]) @overrides def get_releases_endpoint(self, name: str) -> str: - raise NotImplementedError() + raise NotImplementedError @overrides def get_revisions_endpoint(self, name: str) -> str: - raise NotImplementedError() + raise NotImplementedError CHARMHUB: Final = Endpoints( diff --git a/craft_store/errors.py b/craft_store/errors.py index baa8f3b..1481992 100644 --- a/craft_store/errors.py +++ b/craft_store/errors.py @@ -18,10 +18,11 @@ import contextlib import logging -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional import requests -import urllib3 # type: ignore +import urllib3 +import urllib3.exceptions from requests.exceptions import JSONDecodeError logger = logging.getLogger(__name__) @@ -49,7 +50,7 @@ class NetworkError(CraftStoreError): def __init__(self, exception: Exception) -> None: message = str(exception) with contextlib.suppress(IndexError): - if isinstance(exception.args[0], urllib3.exceptions.MaxRetryError): # type: ignore + if isinstance(exception.args[0], urllib3.exceptions.MaxRetryError): message = "Maximum retries exceeded trying to reach the store." super().__init__(message) @@ -77,7 +78,7 @@ def __repr__(self) -> str: return "" def __contains__(self, error_code: str) -> bool: - return any((error.get("code") == error_code for error in self._error_list)) + return any(error.get("code") == error_code for error in self._error_list) def __getitem__(self, error_code: str) -> Dict[str, str]: for error in self._error_list: @@ -100,10 +101,10 @@ class StoreServerError(CraftStoreError): """ def _get_raw_error_list(self) -> List[Dict[str, str]]: - response_json = self.response.json() + response_json: Dict[str, Any] = self.response.json() try: # Charmhub uses error-list. - error_list = response_json["error-list"] + error_list: List[Dict[str, str]] = response_json["error-list"] except KeyError: # Snap Store uses error_list. error_list = response_json["error_list"] @@ -133,21 +134,21 @@ def __init__(self, response: requests.Response) -> None: super().__init__(message) -class CredentialsAlreadyAvailable(CraftStoreError): +class CredentialsAlreadyAvailable(CraftStoreError): # noqa: N818 """Error raised when credentials are already found in the keyring.""" def __init__(self, application: str, host: str) -> None: super().__init__(f"Credentials found for {application!r} on {host!r}.") -class CredentialsUnavailable(CraftStoreError): +class CredentialsUnavailable(CraftStoreError): # noqa: N818 """Error raised when credentials are not found in the keyring.""" def __init__(self, application: str, host: str) -> None: super().__init__(f"No credentials found for {application!r} on {host!r}.") -class CredentialsNotParseable(CraftStoreError): +class CredentialsNotParseable(CraftStoreError): # noqa: N818 """Error raised when credentials are not parseable.""" def __init__(self, msg: str = "Expected base64 encoded credentials") -> None: diff --git a/craft_store/http_client.py b/craft_store/http_client.py index 5c734a9..8e10b04 100644 --- a/craft_store/http_client.py +++ b/craft_store/http_client.py @@ -99,19 +99,19 @@ def __init__(self, *, user_agent: str) -> None: self._session.mount("http://", http_adapter) self._session.mount("https://", http_adapter) - def get(self, *args, **kwargs) -> requests.Response: + def get(self, *args, **kwargs) -> requests.Response: # type: ignore[no-untyped-def] """Perform an HTTP GET request.""" return self.request("GET", *args, **kwargs) - def post(self, *args, **kwargs) -> requests.Response: + def post(self, *args, **kwargs) -> requests.Response: # type: ignore[no-untyped-def] """Perform an HTTP POST request.""" return self.request("POST", *args, **kwargs) - def put(self, *args, **kwargs) -> requests.Response: + def put(self, *args, **kwargs) -> requests.Response: # type: ignore[no-untyped-def] """Perform an HTTP PUT request.""" return self.request("PUT", *args, **kwargs) - def request( + def request( # type: ignore[no-untyped-def] self, method: str, url: str, diff --git a/craft_store/models/_base_model.py b/craft_store/models/_base_model.py index 0dc0ca4..b0a1c3e 100644 --- a/craft_store/models/_base_model.py +++ b/craft_store/models/_base_model.py @@ -26,7 +26,7 @@ class MarshableModel(BaseModel): """A BaseModel that can be marshaled and unmarshaled.""" - class Config: # type: ignore # pylint: disable=too-few-public-methods + class Config: # pylint: disable=too-few-public-methods """Pydantic model configuration.""" validate_assignment = True diff --git a/craft_store/models/revisions_model.py b/craft_store/models/revisions_model.py index 8b9bf25..04ed638 100644 --- a/craft_store/models/revisions_model.py +++ b/craft_store/models/revisions_model.py @@ -16,7 +16,7 @@ """Revisions response models for the Store.""" -from ._base_model import MarshableModel +from craft_store.models._base_model import MarshableModel class RevisionsRequestModel(MarshableModel): diff --git a/craft_store/models/track_guardrail_model.py b/craft_store/models/track_guardrail_model.py index fd85fd7..d367a5a 100644 --- a/craft_store/models/track_guardrail_model.py +++ b/craft_store/models/track_guardrail_model.py @@ -19,11 +19,11 @@ from datetime import datetime from re import Pattern -from craft_store.models import MarshableModel +from craft_store.models._base_model import MarshableModel class TrackGuardrailModel(MarshableModel): """A guardrail regular expression for tracks that can be created.""" - pattern: Pattern + pattern: Pattern # type: ignore[type-arg] created_at: datetime diff --git a/craft_store/models/track_model.py b/craft_store/models/track_model.py index b674762..5a7cbe2 100644 --- a/craft_store/models/track_model.py +++ b/craft_store/models/track_model.py @@ -18,7 +18,7 @@ from datetime import datetime from typing import Optional -from craft_store.models import MarshableModel +from craft_store.models._base_model import MarshableModel class TrackModel(MarshableModel): diff --git a/craft_store/store_client.py b/craft_store/store_client.py index b1b179a..35d4501 100644 --- a/craft_store/store_client.py +++ b/craft_store/store_client.py @@ -20,20 +20,24 @@ import json from typing import Optional -from macaroonbakery import bakery, httpbakery # type: ignore +from macaroonbakery import bakery, httpbakery # type: ignore[import] from overrides import overrides -from pymacaroons.serializers import json_serializer # type: ignore +from pymacaroons import Macaroon # type: ignore[import] +from pymacaroons.serializers import json_serializer # type: ignore[import] from . import creds, endpoints, errors from .base_client import BaseClient from .http_client import HTTPClient -def _macaroon_to_json_string(macaroon) -> str: - return macaroon.serialize(json_serializer.JsonSerializer()) +def _macaroon_to_json_string(macaroon: Macaroon) -> str: + json_string = macaroon.serialize(json_serializer.JsonSerializer()) + if json_string is None: + return "" + return str(json_string) -class WebBrowserWaitingInteractor(httpbakery.WebBrowserInteractor): +class WebBrowserWaitingInteractor(httpbakery.WebBrowserInteractor): # type: ignore[misc] """WebBrowserInteractor implementation using HTTPClient. Waiting for a token is implemented using HTTPClient which mounts @@ -47,7 +51,9 @@ def __init__(self, user_agent: str) -> None: self.user_agent = user_agent # TODO: transfer implementation to macaroonbakery. - def _wait_for_token(self, ctx, wait_token_url): + def _wait_for_token( + self, ctx: Optional[str], wait_token_url: str # noqa: ARG002 + ) -> httpbakery._interactor.DischargeToken: request_client = HTTPClient(user_agent=self.user_agent) resp = request_client.request("GET", wait_token_url) if resp.status_code != 200: @@ -124,9 +130,11 @@ def _authorize_token(self, candid_discharged_macaroon: str) -> str: json={}, ) - return token_exchange_response.json()["macaroon"] + return str(token_exchange_response.json()["macaroon"]) - def _get_discharged_macaroon(self, root_macaroon: str, **kwargs) -> str: + def _get_discharged_macaroon( # type: ignore[no-untyped-def] + self, root_macaroon: str, **_kwargs + ) -> str: candid_discharged_macaroon = self._candid_discharge(root_macaroon) credentials = self._authorize_token(candid_discharged_macaroon) diff --git a/craft_store/ubuntu_one_store_client.py b/craft_store/ubuntu_one_store_client.py index fd3ab21..635bc12 100644 --- a/craft_store/ubuntu_one_store_client.py +++ b/craft_store/ubuntu_one_store_client.py @@ -21,7 +21,7 @@ import requests from overrides import overrides -from pymacaroons import Macaroon # type: ignore +from pymacaroons import Macaroon # type: ignore[import] from . import creds, endpoints, errors from .base_client import BaseClient @@ -87,18 +87,18 @@ def _refresh_token(self) -> None: new_credentials = creds.marshal_u1_credentials(macaroons) self._auth.set_credentials(new_credentials, force=True) - def _extract_caveat_id(self, root_macaroon): + def _extract_caveat_id(self, root_macaroon: str) -> str: macaroon = 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 + return str(caveat.caveat_id) raise errors.CraftStoreError("Invalid root macaroon") def _discharge( - self, email: str, password: str, otp: Optional[str], caveat_id + self, email: str, password: str, otp: Optional[str], caveat_id: str ) -> str: data = {"email": email, "password": password, "caveat_id": caveat_id} if otp: @@ -114,9 +114,11 @@ def _discharge( if not response.ok: raise errors.StoreServerError(response) - return response.json()["discharge_macaroon"] + return str(response.json()["discharge_macaroon"]) - def _get_discharged_macaroon(self, root_macaroon: str, **kwargs) -> str: + def _get_discharged_macaroon( # type: ignore[no-untyped-def] + self, root_macaroon: str, **kwargs + ) -> str: email = kwargs["email"] password = kwargs["password"] otp = kwargs.get("otp") @@ -126,13 +128,11 @@ def _get_discharged_macaroon(self, root_macaroon: str, **kwargs) -> str: email=email, password=password, otp=otp, caveat_id=cavead_id ) - u1_macaroon = creds.UbuntuOneMacaroons( - r=root_macaroon, d=discharged_macaroon - ) # type: ignore + u1_macaroon = creds.UbuntuOneMacaroons(r=root_macaroon, d=discharged_macaroon) return creds.marshal_u1_credentials(u1_macaroon) @overrides - def request( + def request( # type: ignore[no-untyped-def] self, method: str, url: str, @@ -140,6 +140,7 @@ def request( headers: Optional[Dict[str, str]] = None, **kwargs, ) -> requests.Response: + """Make a request to the store.""" try: response = super().request(method, url, params, headers, **kwargs) except errors.StoreServerError as store_error: diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 0a891b6..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -craft_store.rst -craft_store.*.rst -modules.rst diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 0000000..ef7e97f --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,28 @@ +@import url('https://fonts.googleapis.com/css2?family=Ubuntu:ital@0;1&display=swap'); + +body { + font-family: Ubuntu, "times new roman", times, roman, serif; +} + +div .toctree-wrapper { + column-count: 2; +} + +div .toctree-wrapper>ul { + margin: 0; +} + +ul .toctree-l1 { + margin: 0; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; + break-inside: avoid-column; +} + +.wy-nav-content { + max-width: none; +} + +.log-snippets { + color: rgb(141, 141, 141); +} diff --git a/docs/changelog.rst b/docs/changelog.rst index 0fc823b..2a50038 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,7 +15,8 @@ Changelog - :class:`craft_store.base_client.BaseClient.list_registered_names` - Handle keyring unlocking errors -`Full Changelog `_ +`Full Changelog +`_ 2.3.0 (2022-10-07) ------------------ @@ -28,13 +29,14 @@ Changelog - Export :class:`craft_store.models.SnapListReleasesModel` and :class:`craft_store.models.CharmListReleasesModel` -- Remove incorrectly exported `SnapChannelMapModel` and `CharmChannelMapModel` +- Remove incorrectly exported ``SnapChannelMapModel`` and + ``CharmChannelMapModel`` - Make bases optional in :class:`craft_store.models.SnapListReleasesModel` 2.2.0 (2022-08-11) ------------------ -- Refactor common code in `endpoints` +- Refactor common code in ``endpoints`` - Export new symbols in craft_store.models: - :class:`craft_store.models.CharmChannelMapModel` diff --git a/docs/conf.py b/docs/conf.py index 234abfb..f35f5b6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,108 +1,62 @@ -# -# Copyright 2021 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, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# - # Configuration file for the Sphinx documentation builder. # -# This file only contains a selection of the most common options. For a full -# list see the documentation: +# For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -# - -import pathlib -import sys - -sys.path.insert(0, str(pathlib.Path("..").absolute())) - -import craft_store # noqa: E402 - # -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = "Craft Store" -copyright = "2021, Canonical Ltd." -author = "Canonical Ltd." +project = "craft-store" +copyright = "2023, Canonical" +author = "Canonical" -# The full version, including alpha/beta/rc tags -release = craft_store.__version__ +# region General configuration +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", "sphinx.ext.viewcode", - "sphinx_autodoc_typehints", # must be loaded after napoleon + "sphinx.ext.coverage", + "sphinx.ext.doctest", + "sphinx_design", + "sphinx_copybutton", "sphinx-pydantic", + "sphinx_toolbox", + "sphinx_toolbox.more_autodoc", + "sphinx.ext.autodoc", # Must be loaded after more_autodoc ] -# Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +show_authors = False -# -- Options for HTML output ------------------------------------------------- +# endregion +# region Options for HTML output +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". +html_theme = "furo" html_static_path = ["_static"] +html_css_files = [ + "css/custom.css", +] -# Do (not) include module names. -add_module_names = True +# endregion +# region Options for extensions +# Intersphinx extension +# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration -# sphinx_autodoc_typehints +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} + +# Type hints configuration set_type_checking_flag = True typehints_fully_qualified = False always_document_param_types = True -typehints_document_rtype = True - -# Enable support for google-style instance attributes. -napoleon_use_ivar = True - - -def run_apidoc(_): - import os - import sys - - from sphinx.ext.apidoc import main - - sys.path.append(os.path.join(os.path.dirname(__file__), "..")) - cur_dir = os.path.abspath(os.path.dirname(__file__)) - module = os.path.join(cur_dir, "..", "craft_store") - main(["-e", "-o", cur_dir, module, "--no-toc", "--force"]) +# Github config +github_username = "canonical" +github_repository = "craft-store" -def setup(app): - app.connect("builder-inited", run_apidoc) +# endregion diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst new file mode 100644 index 0000000..b9ed902 --- /dev/null +++ b/docs/explanation/index.rst @@ -0,0 +1,7 @@ +.. _explanation: + +Explanation +*********** + +.. toctree:: + :maxdepth: 1 diff --git a/docs/howtos.rst b/docs/howto/index.rst similarity index 94% rename from docs/howtos.rst rename to docs/howto/index.rst index 3d0c8c8..92c26a7 100644 --- a/docs/howtos.rst +++ b/docs/howto/index.rst @@ -1,6 +1,11 @@ -****** -How To -****** +.. _howto: + +How-to guides +************* + +.. toctree:: + :maxdepth: 1 + Using credentials provided by an environment variable ===================================================== @@ -43,7 +48,7 @@ Using retrieved credentials --------------------------- If :class:`craft_store.store_client.StoreClient` is initialized with -`environment_auth` and the value is set then a in-memory +``environment_auth`` and the value is set then a in-memory keyring is used instead of the system keyring. To make use of such thing, export ``CREDENTIALS=`` where @@ -74,7 +79,7 @@ Using craft-cli for upload progress =================================== Progress can be provided by use of craft-cli_. This example will upload -`./test.snap` with something that looks like the following: +``./test.snap`` with something that looks like the following: .. code-block:: python diff --git a/docs/index.rst b/docs/index.rst index 23866ff..ed77a37 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,20 +1,18 @@ +.. craft store documentation root file + ======================================= Welcome to Craft Store's documentation! ======================================= .. toctree:: - :caption: Getting started - :maxdepth: 2 - - tutorials + :maxdepth: 1 + :hidden: - howtos - -.. toctree:: - :caption: Reference: - :maxdepth: 2 + tutorials/index + howto/index + reference/index + explanation/index - craft_store .. toctree:: :caption: About the project diff --git a/docs/reference/index.rst b/docs/reference/index.rst new file mode 100644 index 0000000..75be505 --- /dev/null +++ b/docs/reference/index.rst @@ -0,0 +1,13 @@ +.. _reference: + +Reference +********* + +.. toctree:: + :maxdepth: 1 + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index e1ce92c..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -Sphinx==4.2.0 -sphinx-autodoc-typehints==1.12.0 -sphinx-jsonschema==1.16.11 -sphinx-pydantic==0.1.1 -sphinx-rtd-theme==1.0.0 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.0 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 diff --git a/docs/tutorials.rst b/docs/tutorials/index.rst similarity index 93% rename from docs/tutorials.rst rename to docs/tutorials/index.rst index 5db1598..55c8ae0 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials/index.rst @@ -1,7 +1,15 @@ -********* +.. _tutorial: + Tutorials ********* +If you want to learn the basics from experience, then our tutorials will help +you acquire the necessary competencies from real-life examples with fully +reproducible steps. + +.. toctree:: + :maxdepth: 1 + .. _tutorial-snap_store_login: Login to the Snap Store @@ -83,7 +91,8 @@ Enable the virtual environment and then install Craft Store by running:: Code ---- -Write following into a a text editor and save it as ``snap_store_login_ubuntu_one.py``: +Write following into a a text editor and save it as +``snap_store_login_ubuntu_one.py``: .. code-block:: python @@ -154,7 +163,8 @@ Prerequisites ------------- - Completed :ref:`tutorial-snap_store_login` -- Shelled into the virtual environment created in :ref:`tutorial-snap_store_login` +- Shelled into the virtual environment created in + :ref:`tutorial-snap_store_login` Code ---- @@ -223,8 +233,8 @@ predictable name:: Code for uploading ------------------ -Open a text editor to add logic to instantiate a StoreClient for the Staging Snap -Store: +Open a text editor to add logic to instantiate a StoreClient for the Staging +Snap Store: .. code-block:: python @@ -298,7 +308,7 @@ Save the file. Run --- -Run the saved python module again to upload the *hello* snap and obtain an upload-id -at the end, but observing progress as the upload takes place:: +Run the saved python module again to upload the *hello* snap and obtain an +upload-id at the end, but observing progress as the upload takes place:: $ python snap_store_upload.py diff --git a/pyproject.toml b/pyproject.toml index e8dbe26..ddf8e21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,106 @@ +[project] +name = "craft-store" +dynamic = ["version", "readme"] +dependencies = [ + "keyring>=23.0", + "overrides>=7.0.0", + "requests>=2.26.0", + "requests-toolbelt>=1.0.0", + "macaroonbakery>=1.3.0", + "pydantic>=1.10,<2.0", +] +classifiers = [ + "Development Status :: 1 - Planning", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +requires-python = ">=3.8" + +[project.optional-dependencies] +dev = [ + "build", + "pyyaml>=6.0.0", + "coverage[toml]==7.2.7", + "pytest==7.4.0", + "pytest-cov==4.1.0", + "pytest-mock==3.11.1", + "pytest-check>=2.0", + "pytest-subprocess>=1.5", + "pytest-timeout>=2.0", +] +lint = [ + "black==23.3.0", + "pylint>=2.17.0", + "pylint_fixme_info>=1.0.0", + "pylint_pytest>=1.1.0", + "codespell[toml]==2.2.5", + "ruff==0.0.272", + "yamllint==1.32.0" +] +types = [ + "mypy[reports]==1.4.1", + "pyright==1.1.316", +] +docs = [ + "furo==2023.5.20", + "sphinx>=6.2.1,<7.0", + "sphinx-autobuild==2021.3.14", + "sphinx-autodoc-typehints", + "sphinx-copybutton==0.5.2", + "sphinx-design==0.4.1", + "sphinx-pydantic==0.1.1", + "sphinx-toolbox==3.4.0", + "sphinx-lint==0.6.7", + "sphinx-rtd-theme", +] + +[build-system] +requires = [ + "setuptools==67.7.2", + "setuptools_scm[toml]>=7.1" +] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +readme = {file = "README.rst"} + +[tool.setuptools_scm] +write_to = "craft_store/_version.py" +# the version comes from the latest annotated git tag formatted as 'X.Y.Z' +# version scheme: +# - X.Y.Z.post+g.d<%Y%m%d> +# parts of scheme: +# - X.Y.Z - most recent git tag +# - post+g - present when current commit is not tagged +# - .d<%Y%m%d> - present when working dir is dirty +# version scheme when no tags exist: +# - 0.0.post+g +version_scheme = "post-release" +# deviations from the default 'git describe' command: +# - only match annotated tags +# - only match tags formatted as 'X.Y.Z' +git_describe_command = "git describe --dirty --long --match '[0-9]*.[0-9]*.[0-9]*' --exclude '*[^0-9.]*'" + +[tool.setuptools.packages.find] +include = ["*craft*"] +namespaces = false + +[tool.black] +target-version = ["py38"] + +[tool.codespell] +ignore-words-list = "buildd,crate,keyserver,comandos,ro,dedent,dedented" +skip = ".tox,.git,build,.*_cache,__pycache__,*.tar,*.snap,*.png,./node_modules,./docs/_build,.direnv,.venv,venv,.vscode" +quiet-level = 3 +check-filenames = true + [tool.isort] multi_line_output = 3 include_trailing_comma = true @@ -6,6 +109,22 @@ use_parentheses = true ensure_newline_before_comments = true line_length = 88 +[tool.pytest.ini_options] +minversion = "7.0" +testpaths = "tests" +xfail_strict = true + +[tool.coverage.run] +branch = true +parallel = true +omit = ["tests/**"] + +[tool.coverage.report] +skip_empty = true +exclude_also = [ + "if (typing\\.)?TYPE_CHECKING:", +] + [tool.pylint.messages_control] disable = "too-many-ancestors,too-few-public-methods,fixme,unspecified-encoding,use-implicit-booleaness-not-comparison,unnecessary-lambda-assignment" @@ -22,16 +141,137 @@ extension-pkg-whitelist = [ ] load-plugins = "pylint_fixme_info,pylint_pytest" +[tool.pyright] +strict = ["craft_store"] +pythonVersion = "3.8" +pythonPlatform = "Linux" + [tool.mypy] python_version = "3.8" plugins = ["pydantic.mypy"] -files = [ - "setup.py", - "craft_store", +exclude = [ + "build", "tests", + "results", +] +warn_unused_configs = true +warn_redundant_casts = true +strict_equality = true +strict_concatenate = true +warn_return_any = true +disallow_subclassing_any = true +disallow_untyped_decorators = true +disallow_any_generics = true + +[[tool.mypy.overrides]] +module = ["craft_store.*"] +disallow_untyped_defs = true +no_implicit_optional = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +strict = false + +[tool.ruff] +line-length = 88 +target-version = "py38" +src = ["craft_store", "tests"] +extend-exclude = [ + "docs", + "__pycache__", +] +# Follow ST063 - Maintaining and updating linting specifications for updating these. +select = [ # Base linting rule selections. + # See the internal document for discussion: + # https://docs.google.com/document/d/1i1n8pDmFmWi4wTDpk-JfnWCVUThPJiggyPi2DYwBBu4/edit + # All sections here are stable in ruff and shouldn't randomly introduce + # failures with ruff updates. + "F", # The rules built into Flake8 + "E", "W", # pycodestyle errors and warnings + "I", # isort checking + "N", # PEP8 naming + "D", # Implement pydocstyle checking as well. + "UP", # Pyupgrade - note that some of are excluded below due to Python versions + "YTT", # flake8-2020: Misuse of `sys.version` and `sys.version_info` + "ANN", # Type annotations. + "BLE", # Do not catch blind exceptions + #"FBT", # Disallow boolean positional arguments (make them keyword-only) + "B0", # Common mistakes and typos. + "A", # Shadowing built-ins. + "C4", # Encourage comprehensions, which tend to be faster than alternatives. + "T10", # Don't call the debugger in production code + "ISC", # Implicit string concatenation that can cause subtle issues + "ICN", # Only use common conventions for import aliases. + "INP", # Implicit namespace packages + "PYI", # Linting for type stubs. + "PT", # Pytest + "Q", # Consistent quotations + "RSE", # Errors on pytest raises. + "RET", # Simpler logic after return, raise, continue or break + "SIM", # Code simplification + "TCH004", # Remove imports from type-checking guard blocks if used at runtime + "TCH005", # Delete empty type-checking blocks + "ARG", # Unused arguments + "PTH", # Migrate to pathlib + "ERA", # Don't check in commented out code + "PGH", # Pygrep hooks + "PL", # Pylint + "TRY", # Cleaner try/except, +] +extend-select = [ + # Pyupgrade: https://github.com/charliermarsh/ruff#pyupgrade-up + "UP00", "UP01", "UP02", "UP030", "UP032", "UP033", + # "UP034", # Very new, not yet enabled in ruff 0.0.227 + # Annotations: https://github.com/charliermarsh/ruff#flake8-annotations-ann + "ANN0", # Type annotations for arguments other than `self` and `cls` + "ANN2", # Return type annotations + "B026", # Keyword arguments must come after starred arguments + # flake8-bandit: security testing. https://github.com/charliermarsh/ruff#flake8-bandit-s + # https://bandit.readthedocs.io/en/latest/plugins/index.html#complete-test-plugin-listing + "S101", "S102", # assert or exec + "S103", "S108", # File permissions and tempfiles - use #noqa to silence when appropriate. + "S104", # Network binds + "S105", "S106", "S107", # Hardcoded passwords + "S110", # try-except-pass (use contextlib.suppress instead) + "S113", # Requests calls without timeouts + "S3", # Serialising, deserialising, hashing, crypto, etc. + "S506", # Unsafe YAML load + "S508", "S509", # Insecure SNMP + "S701", # jinja2 templates without autoescape + "RUF001", "RUF002", "RUF003", # Ambiguous unicode characters + "RUF005", # Encourages unpacking rather than concatenation + "RUF008", # Do not use mutable default values for dataclass attributes + "RUF100", # #noqa directive that doesn't flag anything ] +ignore = [ + "ANN10", # Type annotations for `self` and `cls` + "ANN002", # Missing type annotation for `*args` + "ANN003", # Missing type annotation for `**kwargs` + #"E203", # Whitespace before ":" -- Commented because ruff doesn't currently check E203 + "E501", # Line too long (reason: black will automatically fix this for us) + "D105", # Missing docstring in magic method (reason: magic methods already have definitions) + "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) + "D215", # Section underline is over-indented (reason: pep257 default) + "A003", # Class attribute shadowing built-in (reason: Class attributes don't often get bare references) + "PLR0913", # Too many arguments to function call (n > 5) + "PLR2004", # Magic value used in comparison, consider replacing 5 with a constant variable + "SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements + # (reason: this creates long lines that get wrapped and reduces readability) -[tool.pydantic-mypy] -init_typed = true -warn_required_dynamic_aliases = true -warn_untyped_fields = true + # Ignored due to common usage in current code + "TRY003", # Avoid specifying long messages outside the exception class +] + +[tool.ruff.per-file-ignores] +"tests/**.py" = [ # Some things we want for the moin project are unnecessary in tests. + "D", # Ignore docstring rules in tests + "ANN", # Ignore type annotations in tests + "S101", # Allow assertions in tests + "S103", # Allow `os.chmod` setting a permissive mask `0o555` on file or directory + "S108", # Allow Probable insecure usage of temporary file or directory + "PLR0913", # Allow many arguments for test functions +] +# isort leaves init files alone by default, this makes ruff ignore them too. +"__init__.py" = ["I001"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 03d6377..0000000 --- a/setup.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright 2021 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, write to the Free Software Foundation, -# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. -# - - -"""The setup script.""" - -from setuptools import setup - -setup() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ec482bf..b3bbba0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -20,11 +20,10 @@ import pytest import yaml - from craft_store import StoreClient, endpoints -@pytest.fixture +@pytest.fixture() def charm_client(): """A common StoreClient for charms""" return StoreClient( @@ -37,17 +36,17 @@ def charm_client(): ) -@pytest.fixture +@pytest.fixture() def charmhub_charm_name(): """Allow overriding the user to override the test charm. NOTE: Most integration tests check specifics about craft-store-test-charm, so overriding the test charm may cause test failures. """ - yield os.getenv("CRAFT_STORE_TEST_CHARM", default="craft-store-test") + return os.getenv("CRAFT_STORE_TEST_CHARM", default="craft-store-test") -@pytest.fixture +@pytest.fixture() def fake_charm_file(tmpdir, charmhub_charm_name): """Provide a fake charm to upload to charmhub.""" # Make tmpdir Path instead of Path-like. @@ -103,7 +102,7 @@ def fake_charm_file(tmpdir, charmhub_charm_name): return charm_file -@pytest.fixture +@pytest.fixture() def unregistered_charm_name(charm_client): """Get an unregistered name for use in tests""" account_id = charm_client.whoami().get("account", {}).get("id", "").lower() @@ -111,7 +110,7 @@ def unregistered_charm_name(charm_client): while (name := f"test-{account_id}-{uuid.uuid4()}") in registered_names: # Regenerate UUIDs until we find one that's not registered or timeout. pass - yield name + return name def needs_charmhub_credentials(): diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index 09e4fb1..398e870 100644 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -18,15 +18,14 @@ import keyring.backend import keyring.errors import pytest - from craft_store import errors from craft_store.auth import Auth, MemoryKeyring pytestmark = pytest.mark.timeout(10) # Timeout if any test takes over 10 sec. -@pytest.fixture -def test_keyring(): +@pytest.fixture() +def _test_keyring(): """In memory keyring backend for testing.""" current_keyring = keyring.get_keyring() keyring.set_keyring(MemoryKeyring()) @@ -34,7 +33,7 @@ def test_keyring(): keyring.set_keyring(current_keyring) -@pytest.mark.usefixtures("test_keyring") +@pytest.mark.usefixtures("_test_keyring") def test_auth(): auth = Auth("fakecraft", "fakestore.com") @@ -60,6 +59,6 @@ def test_auth(): def test_auth_from_environment(monkeypatch): monkeypatch.setenv("CREDENTIALS", "c2VjcmV0LWtleXM=") - auth = Auth("fakecraft", "fakestore.com", "CREDENTIALS") + auth = Auth("fakecraft", "fakestore.com", environment_auth="CREDENTIALS") assert auth.get_credentials() == "secret-keys" diff --git a/tests/integration/test_get_list_releases.py b/tests/integration/test_get_list_releases.py index ca804d4..ed5876d 100644 --- a/tests/integration/test_get_list_releases.py +++ b/tests/integration/test_get_list_releases.py @@ -19,7 +19,6 @@ from typing import cast import pytest - from craft_store.models import charm_list_releases_model from .conftest import needs_charmhub_credentials diff --git a/tests/integration/test_register_unregister.py b/tests/integration/test_register_unregister.py index 97878ee..544e408 100644 --- a/tests/integration/test_register_unregister.py +++ b/tests/integration/test_register_unregister.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import pytest - from craft_store.errors import StoreServerError from .conftest import needs_charmhub_credentials diff --git a/tests/integration/test_release.py b/tests/integration/test_release.py index 96988f1..407c3cb 100644 --- a/tests/integration/test_release.py +++ b/tests/integration/test_release.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . import pytest - from craft_store.models import release_request_model from .conftest import needs_charmhub_credentials diff --git a/tests/integration/test_upload.py b/tests/integration/test_upload.py index 5f520a0..56637a6 100644 --- a/tests/integration/test_upload.py +++ b/tests/integration/test_upload.py @@ -18,7 +18,6 @@ from typing import cast import pytest - from craft_store.models import revisions_model from .conftest import needs_charmhub_credentials diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index db32dd1..3c2ed81 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -20,7 +20,7 @@ import pytest -@pytest.fixture +@pytest.fixture() def expires(): """Mocks/freezes utcnow() in craft_store.endpoints module. diff --git a/docs/_static/.gitempty b/tests/unit/models/__init__.py similarity index 100% rename from docs/_static/.gitempty rename to tests/unit/models/__init__.py diff --git a/tests/unit/models/test_account_model.py b/tests/unit/models/test_account_model.py index d167999..ccda95d 100644 --- a/tests/unit/models/test_account_model.py +++ b/tests/unit/models/test_account_model.py @@ -16,7 +16,6 @@ # """Tests for the store account model.""" import pytest - from craft_store.models.account_model import AccountModel BASIC_ACCOUNT = {"id": "123"} @@ -30,13 +29,14 @@ @pytest.mark.parametrize( - "json_dict,expected", + ("json_dict", "expected"), [ pytest.param(BASIC_ACCOUNT, AccountModel(id="123"), id="basic"), pytest.param( FULL_ACCOUNT, AccountModel( - display_name="Display Name", # pyright: ignore + display_name="Display Name", # pyright: ignore[reportGeneralTypeIssues] + # bug https://github.com/pydantic/pydantic/discussions/3986 id="abc123", username="usso-username", validation="unproven", diff --git a/tests/unit/models/test_charm_list_releases_model.py b/tests/unit/models/test_charm_list_releases_model.py index b69634c..f34d59a 100644 --- a/tests/unit/models/test_charm_list_releases_model.py +++ b/tests/unit/models/test_charm_list_releases_model.py @@ -17,11 +17,10 @@ import datetime import pytest - from craft_store.models import charm_list_releases_model -@pytest.fixture +@pytest.fixture() def payload(): return { "channel-map": [ diff --git a/tests/unit/models/test_registered_name_model.py b/tests/unit/models/test_registered_name_model.py index 13bc124..fd7d8cb 100644 --- a/tests/unit/models/test_registered_name_model.py +++ b/tests/unit/models/test_registered_name_model.py @@ -19,7 +19,6 @@ from datetime import datetime import pytest - from craft_store.models import ( AccountModel, RegisteredNameModel, @@ -82,7 +81,7 @@ def test_unmarshal(check, json_dict): actual.media, [MediaModel.unmarshal(m) for m in json_dict.get("media", [])] ) check.equal(actual.name, json_dict.get("name")) - check.equal(actual.private, True if json_dict["private"] == "true" else False) + check.equal(actual.private, json_dict["private"] == "true") check.equal(actual.publisher, AccountModel.unmarshal(json_dict["publisher"])) check.equal(actual.status, json_dict.get("status")) check.equal(actual.store, json_dict.get("store")) diff --git a/tests/unit/models/test_snap_list_releases_model.py b/tests/unit/models/test_snap_list_releases_model.py index e7822f8..1208c86 100644 --- a/tests/unit/models/test_snap_list_releases_model.py +++ b/tests/unit/models/test_snap_list_releases_model.py @@ -17,11 +17,10 @@ import datetime import pytest - from craft_store.models import snap_list_releases_model -@pytest.fixture +@pytest.fixture() def payload(): return { "channel-map": [ diff --git a/tests/unit/models/test_track_guardrail_model.py b/tests/unit/models/test_track_guardrail_model.py index adec172..18bf4a7 100644 --- a/tests/unit/models/test_track_guardrail_model.py +++ b/tests/unit/models/test_track_guardrail_model.py @@ -19,14 +19,13 @@ from datetime import datetime, timezone import pytest - from craft_store.models.track_guardrail_model import TrackGuardrailModel GUARDRAIL_DICT = {"created-at": "2023-03-28T18:50:44+00:00", "pattern": r"^\d\.\d/"} @pytest.mark.parametrize( - "json_dict,expected", + ("json_dict", "expected"), [ pytest.param( GUARDRAIL_DICT, @@ -36,7 +35,7 @@ "created-at": datetime( 2023, 3, 28, 18, 50, 44, tzinfo=timezone.utc ), - } + }, ), ), ], diff --git a/tests/unit/models/test_track_model.py b/tests/unit/models/test_track_model.py index b215bc8..c7258bf 100644 --- a/tests/unit/models/test_track_model.py +++ b/tests/unit/models/test_track_model.py @@ -18,7 +18,6 @@ from datetime import datetime import pytest - from craft_store.models.track_model import TrackModel BASIC_TRACK = {"created-at": "2023-03-28T18:50:44+00:00", "name": "1.0/stable"} diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 3d9b31a..f2cdad7 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -22,7 +22,6 @@ import keyring.backends.fail import keyring.errors import pytest - from craft_store import errors from craft_store.auth import Auth, MemoryKeyring @@ -57,7 +56,7 @@ def delete_password(self, *args): raise self.delete_error # pylint: disable=raising-bad-type -@pytest.fixture +@pytest.fixture() def keyring_set_keyring_mock(): """Mock setting the keyring.""" @@ -67,7 +66,7 @@ def keyring_set_keyring_mock(): patched_keyring.stop() -@pytest.fixture +@pytest.fixture() def fake_keyring(): return FakeKeyring() @@ -134,7 +133,7 @@ def test_double_set_credentials_force(fake_keyring): def test_get_credentials(caplog, fake_keyring): - fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" + fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" # noqa: S105 auth = Auth("fakeclient", "fakestore.com") @@ -145,7 +144,7 @@ def test_get_credentials(caplog, fake_keyring): def test_get_credentials_log_debug(caplog, fake_keyring): caplog.set_level(logging.DEBUG) - fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" + fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" # noqa: S105 auth = Auth("fakeclient", "fakestore.com") @@ -167,7 +166,7 @@ def test_get_credentials_no_credentials_in_keyring(caplog, fake_keyring): def test_del_credentials(caplog, fake_keyring): - fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" + fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" # noqa: S105 auth = Auth("fakeclient", "fakestore.com") @@ -179,7 +178,7 @@ def test_del_credentials(caplog, fake_keyring): def test_del_credentials_log_debug(caplog, fake_keyring): caplog.set_level(logging.DEBUG) - fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" + fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" # noqa: S105 auth = Auth("fakeclient", "fakestore.com") @@ -194,7 +193,7 @@ def test_del_credentials_log_debug(caplog, fake_keyring): def test_del_credentials_delete_error_in_keyring(caplog, fake_keyring): fake_keyring.delete_error = keyring.errors.PasswordDeleteError() - fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" + fake_keyring.password = "eydwYXNzd29yZCc6ICdzZWNyZXQnfQ==" # noqa: S105 auth = Auth("fakeclient", "fakestore.com") @@ -249,7 +248,7 @@ def test_ensure_no_credentials_unlock_error(fake_keyring, mocker): auth.ensure_no_credentials() -@pytest.mark.disable_fake_keyring +@pytest.mark.disable_fake_keyring() def test_ephemeral_set_memory_keyring(): auth = Auth("fakeclient", "fakestore.com", ephemeral=True) diff --git a/tests/unit/test_base_client.py b/tests/unit/test_base_client.py index 42593fd..baefe68 100644 --- a/tests/unit/test_base_client.py +++ b/tests/unit/test_base_client.py @@ -19,7 +19,6 @@ import pytest import requests - from craft_store import BaseClient, endpoints from craft_store.models import AccountModel, RegisteredNameModel @@ -57,7 +56,7 @@ id="pubid", username="usso-someone", validation="unproven", - **{"display-name": "Charmhub Publisher"} + **{"display-name": "Charmhub Publisher"}, ) @@ -65,11 +64,13 @@ class ConcreteTestClient(BaseClient): def _get_authorization_header(self) -> str: return "I am authorised." - def _get_discharged_macaroon(self, root_macaroon: str, **kwargs) -> str: + def _get_discharged_macaroon( + self, root_macaroon: str, **kwargs # noqa: ARG002 + ) -> str: return "The voltmeter reads 0V over this macaroon." -@pytest.fixture +@pytest.fixture() def charm_client(): client = ConcreteTestClient( base_url="https://staging.example.com", @@ -79,11 +80,11 @@ def charm_client(): user_agent="craft-store unit tests, should not be hitting a real server", ) client.http_client = Mock(spec=client.http_client) - yield client + return client @pytest.mark.parametrize( - "content,expected", + ("content", "expected"), [ (b'{"results":[]}', []), ( @@ -111,7 +112,7 @@ def test_list_registered_names(charm_client, content, expected): @pytest.mark.parametrize( - ["name", "entity_type", "private", "team", "expected_json"], + ("name", "entity_type", "private", "team", "expected_json"), [ pytest.param( "test-charm-abcxyz", diff --git a/tests/unit/test_creds.py b/tests/unit/test_creds.py index d86eb34..ccc9f1e 100644 --- a/tests/unit/test_creds.py +++ b/tests/unit/test_creds.py @@ -17,11 +17,10 @@ import json import pytest - from craft_store import creds, errors -@pytest.fixture +@pytest.fixture() def stored_candid_creds(new_auth: bool) -> str: """Fixture that generates Candid credentials in the format read from storage. @@ -35,7 +34,7 @@ def stored_candid_creds(new_auth: bool) -> str: return candid_creds -@pytest.fixture +@pytest.fixture() def stored_u1_creds(new_auth: bool) -> str: """Fixture that generates Ubuntu One credentials in the format read from storage. diff --git a/tests/unit/test_endpoints.py b/tests/unit/test_endpoints.py index 10f9fcf..17444d4 100644 --- a/tests/unit/test_endpoints.py +++ b/tests/unit/test_endpoints.py @@ -15,7 +15,6 @@ # along with this program. If not, see . import pytest - from craft_store import endpoints @@ -78,7 +77,7 @@ def test_charmhub_packages(): def test_charmhub_invalid_packages(): charmhub = endpoints.CHARMHUB - with pytest.raises(ValueError) as raised: + with pytest.raises(ValueError) as raised: # noqa: PT011 charmhub.get_token_request( permissions=["permission-foo", "permission-bar"], description="client description", @@ -169,7 +168,7 @@ def test_snap_store_packages(expires): def test_snap_store_invalid_packages(): snap_store = endpoints.SNAP_STORE - with pytest.raises(ValueError) as raised: + with pytest.raises(ValueError) as raised: # noqa: PT011 snap_store.get_token_request( permissions=["permission-foo", "permission-bar"], description="client description", diff --git a/tests/unit/test_errors.py b/tests/unit/test_errors.py index de74444..49562ff 100644 --- a/tests/unit/test_errors.py +++ b/tests/unit/test_errors.py @@ -19,10 +19,10 @@ import pytest import requests -import urllib3 # type: ignore -from requests.exceptions import JSONDecodeError - +import urllib3 +import urllib3.exceptions from craft_store import errors +from requests.exceptions import JSONDecodeError def _fake_error_response(status_code, reason, json=None): @@ -47,7 +47,7 @@ def _fake_error_response(status_code, reason, json=None): "exception_class": errors.NetworkError, "args": [ requests.exceptions.ConnectionError( - urllib3.exceptions.MaxRetryError( # type: ignore + urllib3.exceptions.MaxRetryError( pool=urllib3.connectionpool.ConnectionPool("https://foo.bar"), url="test-url", ) @@ -145,7 +145,7 @@ def test_error_formatting(scenario): ] -@pytest.mark.parametrize("error_list_key", ("error-list", "error_list")) +@pytest.mark.parametrize("error_list_key", ["error-list", "error_list"]) @pytest.mark.parametrize("error_list", error_lists) def test_store_error_list(error_list_key, error_list): response = _fake_error_response( @@ -154,8 +154,8 @@ def test_store_error_list(error_list_key, error_list): assert str(errors.StoreServerError(response)) == error_list["expected"] -@pytest.mark.parametrize("missing", ("code", "message")) -@pytest.mark.parametrize("error_list_key", ("error-list", "error_list")) +@pytest.mark.parametrize("missing", ["code", "message"]) +@pytest.mark.parametrize("error_list_key", ["error-list", "error_list"]) def test_store_error_list_missing_element(missing, error_list_key): error_list = [ {"code": "resource-not-found", "message": "could not find resource"}, diff --git a/tests/unit/test_http_client.py b/tests/unit/test_http_client.py index 1f9a03e..d5473a9 100644 --- a/tests/unit/test_http_client.py +++ b/tests/unit/test_http_client.py @@ -19,11 +19,11 @@ import pytest import requests -import urllib3 # type: ignore -from requests.exceptions import JSONDecodeError - +import urllib3 +import urllib3.exceptions from craft_store import HTTPClient, errors from craft_store.http_client import _get_retry_value +from requests.exceptions import JSONDecodeError def _fake_error_response(status_code, reason, json_raises=False): @@ -36,7 +36,7 @@ def _fake_error_response(status_code, reason, json_raises=False): return response -@pytest.fixture +@pytest.fixture() def session_mock(): patched_session = patch("requests.Session", autospec=True) mocked_session = patched_session.start() @@ -45,7 +45,7 @@ def session_mock(): patched_session.stop() -@pytest.fixture +@pytest.fixture() def retry_mock(): patched_retry = patch("craft_store.http_client.Retry", autospec=True) yield patched_retry.start() @@ -79,7 +79,7 @@ def test_session_environment_values(monkeypatch, session_mock, retry_mock): ) -@pytest.mark.parametrize("method", ("get", "post", "put")) +@pytest.mark.parametrize("method", ["get", "post", "put"]) def test_methods(session_mock, method): client = HTTPClient(user_agent="Secret Agent") getattr(client, method)("https://foo.bar") @@ -178,12 +178,14 @@ def test_request_500(session_mock): "GET", "https://foo.bar", ) - assert store_error.response == fake_response # type: ignore + + assert store_error.value.response == fake_response def test_request_connection_error(session_mock): + connection_pool = urllib3.connectionpool.ConnectionPool("https://foo.bar") connection_error = requests.exceptions.ConnectionError( - urllib3.exceptions.MaxRetryError(pool="test-pool", url="test-url") # type: ignore + urllib3.exceptions.MaxRetryError(pool=connection_pool, url="test-url") ) session_mock().request.side_effect = connection_error @@ -192,7 +194,8 @@ def test_request_connection_error(session_mock): "GET", "https://foo.bar", ) - assert network_error.__cause__ == connection_error # type: ignore + + assert network_error.value.__cause__ == connection_error def test_request_retry_error(session_mock): @@ -204,11 +207,11 @@ def test_request_retry_error(session_mock): "GET", "https://foo.bar", ) - assert network_error.__cause__ == retry_error # type: ignore - assert network_error.exception == retry_error # type: ignore + + assert network_error.value.__cause__ == retry_error -@pytest.mark.parametrize("environment_value", ("0", "10", "20000")) +@pytest.mark.parametrize("environment_value", ["0", "10", "20000"]) def test_get_retry_value_environment_override(monkeypatch, caplog, environment_value): monkeypatch.setenv("FAKE_ENV", environment_value) caplog.set_level(logging.DEBUG) @@ -218,7 +221,7 @@ def test_get_retry_value_environment_override(monkeypatch, caplog, environment_v @pytest.mark.parametrize( - "environment_value,default", [("NaN", 10), ("NaN", 0.4), ("foo", 1)] + ("environment_value", "default"), [("NaN", 10), ("NaN", 0.4), ("foo", 1)] ) def test_get_retry_value_not_a_number_returns_default( monkeypatch, caplog, environment_value, default @@ -232,7 +235,7 @@ def test_get_retry_value_not_a_number_returns_default( ] == [rec.message for rec in caplog.records] -@pytest.mark.parametrize("environment_value,default", [("-1", 10), ("-1000", 0.4)]) +@pytest.mark.parametrize(("environment_value", "default"), [("-1", 10), ("-1000", 0.4)]) def test_get_retry_value_negative_number_returns_default( monkeypatch, caplog, environment_value, default ): diff --git a/tests/unit/test_store_client.py b/tests/unit/test_store_client.py index 1d6f0b3..2abe42d 100644 --- a/tests/unit/test_store_client.py +++ b/tests/unit/test_store_client.py @@ -18,11 +18,10 @@ from unittest.mock import ANY, Mock, call, patch import pytest -from macaroonbakery import bakery, httpbakery # type: ignore -from pymacaroons.macaroon import Macaroon # type: ignore - from craft_store import Auth, base_client, creds, endpoints, errors from craft_store.store_client import StoreClient, WebBrowserWaitingInteractor +from macaroonbakery import bakery, httpbakery +from pymacaroons.macaroon import Macaroon def _fake_response(status_code, reason=None, json=None): @@ -35,7 +34,7 @@ def _fake_response(status_code, reason=None, json=None): return response -@pytest.fixture +@pytest.fixture() def real_macaroon(): return json.dumps( { @@ -54,9 +53,9 @@ def real_macaroon(): ) -@pytest.fixture +@pytest.fixture() def http_client_request_mock(real_macaroon): - def request(*args, **kwargs): # pylint: disable=W0613 + def request(*args, **kwargs): # noqa: ARG001 if args[1] == "POST" and "tokens" in args[2]: response = _fake_response(200, json={"macaroon": real_macaroon}) elif args[1] == "GET" and "whoami" in args[2]: @@ -64,17 +63,9 @@ def request(*args, **kwargs): # pylint: disable=W0613 200, json={"name": "Fake Person", "username": "fakeuser", "id": "fake-id"}, ) - elif ( - args[1] == "POST" - and args[2] == "https://fake-charm-storage.com/unscanned-upload/" - ): - response = _fake_response( - 200, - json={"upload_id": "12345", "successful": True}, - ) - elif ( - args[1] == "POST" - and args[2] == "https://fake-snap-storage.com/unscanned-upload/" + elif args[1] == "POST" and args[2] in ( + "https://fake-charm-storage.com/unscanned-upload/", + "https://fake-snap-storage.com/unscanned-upload/", ): response = _fake_response( 200, @@ -95,8 +86,8 @@ def request(*args, **kwargs): # pylint: disable=W0613 patched_http_client.stop() -@pytest.fixture -def bakery_discharge_mock(monkeypatch): +@pytest.fixture() +def _bakery_discharge_mock(monkeypatch): token_response_mock = _fake_response( 200, json={"kind": "kind", "token": "TOKEN", "token64": b"VE9LRU42NA=="} ) @@ -104,7 +95,7 @@ def bakery_discharge_mock(monkeypatch): httpbakery.Client, "acquire_discharge", lambda: token_response_mock ) - def mock_discharge(*args, **kwargs): # pylint: disable=W0613 + def mock_discharge(*args, **kwargs): # noqa: ARG001 return [ Macaroon( location="fake-server.com", @@ -115,7 +106,7 @@ def mock_discharge(*args, **kwargs): # pylint: disable=W0613 monkeypatch.setattr(bakery, "discharge_all", mock_discharge) -@pytest.fixture +@pytest.fixture() def auth_mock(real_macaroon, new_auth): patched_auth = patch("craft_store.base_client.Auth", autospec=True) mocked_auth = patched_auth.start() @@ -129,9 +120,9 @@ def auth_mock(real_macaroon, new_auth): patched_auth.stop() -@pytest.mark.usefixtures("bakery_discharge_mock") -@pytest.mark.parametrize("ephemeral_auth", (True, False)) -@pytest.mark.parametrize("environment_auth", (None, "APPLICATION_CREDENTIALS")) +@pytest.mark.usefixtures("_bakery_discharge_mock") +@pytest.mark.parametrize("ephemeral_auth", [True, False]) +@pytest.mark.parametrize("environment_auth", [None, "APPLICATION_CREDENTIALS"]) def test_store_client_login( http_client_request_mock, real_macaroon, auth_mock, environment_auth, ephemeral_auth ): @@ -188,7 +179,7 @@ def test_store_client_login( ] -@pytest.mark.usefixtures("bakery_discharge_mock") +@pytest.mark.usefixtures("_bakery_discharge_mock") def test_store_client_login_with_packages_and_channels( http_client_request_mock, real_macaroon, auth_mock ): @@ -332,9 +323,7 @@ def test_store_client_whoami(http_client_request_mock, real_macaroon, auth_mock) @pytest.mark.parametrize("hub", [endpoints.CHARMHUB, endpoints.SNAP_STORE]) -def test_store_client_upload_file_no_monitor( - tmp_path, http_client_request_mock, auth_mock, hub -): +def test_store_client_upload_file_no_monitor(tmp_path, http_client_request_mock, hub): if hub == endpoints.CHARMHUB: storage_url = "https://fake-charm-storage.com" else: @@ -378,9 +367,7 @@ def test_store_client_upload_file_no_monitor( @pytest.mark.parametrize("hub", [endpoints.CHARMHUB, endpoints.SNAP_STORE]) -def test_store_client_upload_file_with_monitor( - tmp_path, http_client_request_mock, auth_mock, hub -): +def test_store_client_upload_file_with_monitor(tmp_path, http_client_request_mock, hub): if hub == endpoints.CHARMHUB: storage_url = "https://fake-charm-storage.com" else: @@ -397,10 +384,10 @@ def test_store_client_upload_file_with_monitor( filepath = tmp_path / "artifact.thing" filepath.write_text("file to upload") - def callback(monitor): # pylint: disable=unused-argument + def callback(monitor): # noqa: ARG001 pass - def monitor(encoder): # pylint: disable=unused-argument + def monitor(encoder): # noqa: ARG001 return callback with patch("craft_store.base_client.MultipartEncoder"): @@ -452,7 +439,7 @@ def monitor(encoder): # pylint: disable=unused-argument ] -def test_webinteractore_wait_for_token(http_client_request_mock, auth_mock): +def test_webinteractore_wait_for_token(http_client_request_mock): http_client_request_mock.side_effect = None http_client_request_mock.return_value = _fake_response( 200, json={"kind": "kind", "token": "TOKEN", "token64": b"VE9LRU42NA=="} @@ -460,9 +447,7 @@ def test_webinteractore_wait_for_token(http_client_request_mock, auth_mock): wbi = WebBrowserWaitingInteractor(user_agent="foobar") - discharged_token = wbi._wait_for_token( # pylint: disable=W0212 - object(), "https://foo.bar/candid" - ) + discharged_token = wbi._wait_for_token(None, "https://foo.bar/candid") assert discharged_token == httpbakery.DischargeToken(kind="kind", value="TOKEN") assert http_client_request_mock.mock_calls == [ @@ -471,7 +456,7 @@ def test_webinteractore_wait_for_token(http_client_request_mock, auth_mock): def test_webinteractore_wait_for_token_timeout_error( - http_client_request_mock, auth_mock + http_client_request_mock, ): http_client_request_mock.side_effect = None http_client_request_mock.return_value = _fake_response(400, json={}) @@ -479,20 +464,20 @@ def test_webinteractore_wait_for_token_timeout_error( wbi = WebBrowserWaitingInteractor(user_agent="foobar") with pytest.raises(errors.CandidTokenTimeoutError): - wbi._wait_for_token(object(), "https://foo.bar/candid") # pylint: disable=W0212 + wbi._wait_for_token(None, "https://foo.bar/candid") -def test_webinteractore_wait_for_token_kind_error(http_client_request_mock, auth_mock): +def test_webinteractore_wait_for_token_kind_error(http_client_request_mock): http_client_request_mock.side_effect = None http_client_request_mock.return_value = _fake_response(200, json={}) wbi = WebBrowserWaitingInteractor(user_agent="foobar") with pytest.raises(errors.CandidTokenKindError): - wbi._wait_for_token(object(), "https://foo.bar/candid") # pylint: disable=W0212 + wbi._wait_for_token(None, "https://foo.bar/candid") -def test_webinteractore_wait_for_token_value_error(http_client_request_mock, auth_mock): +def test_webinteractore_wait_for_token_value_error(http_client_request_mock): http_client_request_mock.side_effect = None http_client_request_mock.return_value = _fake_response( 200, @@ -504,7 +489,7 @@ def test_webinteractore_wait_for_token_value_error(http_client_request_mock, aut wbi = WebBrowserWaitingInteractor(user_agent="foobar") with pytest.raises(errors.CandidTokenValueError): - wbi._wait_for_token(object(), "https://foo.bar/candid") # pylint: disable=W0212 + wbi._wait_for_token(None, "https://foo.bar/candid") def test_store_client_env_var(http_client_request_mock, new_auth, monkeypatch): diff --git a/tests/unit/test_ubuntu_one_store_client.py b/tests/unit/test_ubuntu_one_store_client.py index 2d01a20..fa207ae 100644 --- a/tests/unit/test_ubuntu_one_store_client.py +++ b/tests/unit/test_ubuntu_one_store_client.py @@ -18,10 +18,9 @@ from unittest.mock import Mock, call, patch import pytest -from pymacaroons import Caveat, Macaroon # type: ignore - from craft_store import creds, endpoints, errors from craft_store.ubuntu_one_store_client import UbuntuOneStoreClient +from pymacaroons import Caveat, Macaroon def _fake_response(status_code, reason=None, headers=None, json=None): @@ -35,7 +34,7 @@ def _fake_response(status_code, reason=None, headers=None, json=None): return response -@pytest.fixture +@pytest.fixture() def root_macaroon(): return Macaroon( location="fake-server.com", @@ -50,7 +49,7 @@ def root_macaroon(): ).serialize() -@pytest.fixture +@pytest.fixture() def discharged_macaroon(): return Macaroon( location="fake-server.com", @@ -58,25 +57,25 @@ def discharged_macaroon(): ).serialize() -@pytest.fixture +@pytest.fixture() def u1_macaroon_value(root_macaroon, discharged_macaroon): """The basic "payload" for the u1-macaroon auth type""" return {"r": root_macaroon, "d": discharged_macaroon} -@pytest.fixture +@pytest.fixture() def old_credentials(u1_macaroon_value): """u1-macaroon credentials encoded in the *old* ("type-less") scheme.""" return json.dumps(u1_macaroon_value) -@pytest.fixture +@pytest.fixture() def new_credentials(u1_macaroon_value): """u1-macaroon credentials encoded in the *new* ("typed") scheme.""" return creds.marshal_u1_credentials(creds.UbuntuOneMacaroons(**u1_macaroon_value)) -@pytest.fixture +@pytest.fixture() def authorization(): return ( "Macaroon " @@ -85,7 +84,7 @@ def authorization(): ) -@pytest.fixture +@pytest.fixture() def http_client_request_mock(root_macaroon, discharged_macaroon): def request(*args, **kwargs): # pylint: disable=W0613 if args[1] == "POST" and "tokens/discharge" in args[2]: @@ -132,7 +131,7 @@ def request(*args, **kwargs): # pylint: disable=W0613 patched_http_client.stop() -@pytest.fixture +@pytest.fixture() def auth_mock(old_credentials, new_credentials, new_auth): patched_auth = patch("craft_store.base_client.Auth", autospec=True) mocked_auth = patched_auth.start() @@ -146,7 +145,7 @@ def auth_mock(old_credentials, new_credentials, new_auth): patched_auth.stop() -@pytest.mark.parametrize("environment_auth", (None, "APPLICATION_CREDENTIALS")) +@pytest.mark.parametrize("environment_auth", [None, "APPLICATION_CREDENTIALS"]) def test_store_client_login( http_client_request_mock, new_credentials, @@ -170,7 +169,7 @@ def test_store_client_login( description="fakecraft@foo", ttl=60, email="foo@bar.com", - password="password", + password="password", # noqa: S106 ) == new_credentials ) @@ -234,7 +233,7 @@ def test_store_client_login_otp( description="fakecraft@foo", ttl=60, email="otp@foo.bar", - password="password", + password="password", # noqa: S106 ) assert "twofactor-required" in server_error.value.error_list @@ -244,7 +243,7 @@ def test_store_client_login_otp( description="fakecraft@foo", ttl=60, email="otp@foo.bar", - password="password", + password="password", # noqa: S106 otp="123456", ) == new_credentials @@ -332,7 +331,7 @@ def test_store_client_login_with_packages_and_channels( endpoints.Package("my-other-snap", "snap"), ], email="foo@bar.com", - password="password", + password="password", # noqa: S106 ) == new_credentials ) diff --git a/tools/freeze-requirements.sh b/tools/freeze-requirements.sh deleted file mode 100755 index 07cd4b1..0000000 --- a/tools/freeze-requirements.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/bash -eux - -venv_dir="$(mktemp -d)" - -python3 -m venv "$venv_dir" -. "$venv_dir/bin/activate" - -pip install -e . -pip freeze --exclude-editable > requirements.txt - -pip install -e .[dev] -pip freeze --exclude-editable > requirements-dev.txt - -rm -rf "$venv_dir" diff --git a/tox.ini b/tox.ini index dc4132e..40f866f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,149 @@ [tox] -envlist = py38 - -[testenv] -setenv = - PYTHONPATH = {toxinidir} -deps = - -r{toxinidir}/requirements-dev.txt - -r{toxinidir}/requirements.txt +env_list = # Environments to run when called with no parameters. + format-{black,ruff,codespell} + pre-commit + lint-{black,ruff,mypy,pyright,shellcheck,codespell,docs,yaml,pylint} + unit-py3.{8,10,11} + integration-py3.10 +# Integration tests probably take a while, so we're only running them on Python +# 3.10, which is included in core22. +minversion = 4.6 +# Tox will use these requirements to bootstrap a venv if necessary. +# tox-igore-env-name-mismatch allows us to have one virtualenv for all linting. +# By setting requirements here, we make this INI file compatible with older +# versions of tox. Tox >= 3.8 will automatically provision the version provided +# inside of a virtual environment, so users of Ubuntu >= focal can simply +# install tox from apt. Older than that, the user gets an upgrade warning. +requires = + # renovate: datasource=pypi + tox-ignore-env-name-mismatch>=0.2.0.post2 + # renovate: datasource=pypi + tox-gh==1.2.0 +# Allow tox to access the user's $TMPDIR environment variable if set. +# This workaround is required to avoid circular dependencies for TMPDIR, +# since tox will otherwise attempt to use the environment's TMPDIR variable. +user_tmp_dir = {env:TMPDIR} + +[testenv] # Default config for all environments. Overridable in each env. +# We have many tests that create temporary files. Unless the user has set a +# TMPDIR, this will prefer putting those temp files in $XDG_RUNTIME_DIR, +# which will speed up those tests since they'll run on a ramdisk. +env_tmp_dir = {user_tmp_dir:{env:XDG_RUNTIME_DIR:{work_dir}}}/tox_tmp/{env_name} +set_env = + TMPDIR={env_tmp_dir} + COVERAGE_FILE={env_tmp_dir}/.coverage_{env_name} +pass_env = + CI + CRAFT_* + PYTEST_ADDOPTS + +[test] # Base configuration for unit and integration tests +package = editable +extras = dev +allowlist_externals = mkdir +commands_pre = mkdir -p {tox_root}/results + +[testenv:{unit,integration}-py3.{8,9,10,11,12}] # Configuration for all tests using pytest +base = testenv, test +description = + unit: Run unit tests with pytest + integration: Run integration tests with pytest +labels = + py3.{8,10,11}: tests + unit-py3.{8,10,11}: unit-tests + integration-py3.{8,10,11}: integration-tests +change_dir = + unit: tests/unit + integration: tests/integration +commands = pytest {tty:--color=yes} --cov={tox_root}/starcraft --cov-config={tox_root}/pyproject.toml --cov-report=xml:{tox_root}/results/coverage-{env_name}.xml --junit-xml={tox_root}/results/test-results-{env_name}.xml {posargs} + +[lint] # Standard linting configuration +package = editable +extras = lint +env_dir = {work_dir}/linting +runner = ignore_env_name_mismatch + +[shellcheck] +find = git ls-files +filter = file --mime-type -Nnf- | grep shellscript | cut -f1 -d: + +[testenv:lint-{black,ruff,shellcheck,codespell,yaml,pylint}] +description = Lint the source code +base = testenv, lint +labels = lint +allowlist_externals = + shellcheck: bash, xargs +commands_pre = + shellcheck: bash -c '{[shellcheck]find} | {[shellcheck]filter} > {env_tmp_dir}/shellcheck_files' commands = - pip install -U pip - pytest --basetemp={envtmpdir} + black: black --check --diff {tty:--color} {posargs} . + ruff: ruff check --respect-gitignore {posargs} . + shellcheck: xargs -ra {env_tmp_dir}/shellcheck_files shellcheck + codespell: codespell --toml {tox_root}/pyproject.toml {posargs} + yaml: yamllint {posargs} . + pylint: pylint {posargs} craft_store --ignore _version.py + +[testenv:lint-{mypy,pyright}] +description = Static type checking +base = testenv, lint +env_dir = {work_dir}/typing +extras = dev, types +labels = lint, type +allowlist_externals = + mypy: mkdir +commands_pre = + mypy: mkdir -p {tox_root}/.mypy_cache +commands = + pyright: pyright {posargs} + mypy: mypy --install-types --non-interactive {posargs:.} + +[testenv:format-{black,ruff,codespell}] +description = Automatically format source code +base = testenv, lint +labels = format +commands = + black: black {tty:--color} {posargs} . + ruff: ruff --fix --respect-gitignore {posargs} . + codespell: codespell --toml {tox_root}/pyproject.toml --write-changes {posargs} + +[testenv:pre-commit] +base = +deps = pre-commit +package = skip +no_package = true +env_dir = {work_dir}/pre-commit +runner = ignore_env_name_mismatch +description = Run pre-commit on staged files or arbitrary pre-commit commands (tox run -e pre-commit -- [args]) +commands = pre-commit {posargs:run} -[testenv:docs] -commands = make docs +[docs] # Sphinx documentation configuration +extras = docs +package = editable +no_package = true +env_dir = {work_dir}/docs +runner = ignore_env_name_mismatch +source_dir = {tox_root}/{project_name} -[testenv:lint] -commands = make lint +[testenv:build-docs] +description = Build sphinx documentation +base = docs +allowlist_externals = bash +commands_pre = bash -c 'if [[ ! -e docs ]];then echo "No docs directory. Run `tox run -e sphinx-quickstart` to create one.;";return 1;fi' +# "-W" is to treat warnings as errors +commands = sphinx-build {posargs:-b html} -W {tox_root}/docs {tox_root}/docs/_build -[testenv:integrations] -commands = make test-integrations +[testenv:autobuild-docs] +description = Build documentation with an autoupdating server +base = docs +commands = sphinx-autobuild {posargs:-b html --open-browser --port 8080} -W --watch {source_dir} {tox_root}/docs {tox_root}/docs/_build -[testenv:units] -commands = make test-units +[lint-docs] +find = git ls-files -[pycodestyle] -ignore = E501 +[testenv:lint-docs] +description = Lint the documentation with sphinx-lint +base = docs +labels = lint +allowlist_externals = bash, xargs +commands_pre = bash -c '{[lint-docs]find} > {env_tmp_dir}/lint_docs_files' +commands = xargs --no-run-if-empty --arg-file {env_tmp_dir}/lint_docs_files sphinx-lint --max-line-length 80 --enable all {posargs}