diff --git a/.github/workflows/pinact.yaml b/.github/workflows/pinact.yaml new file mode 100644 index 00000000..ce2582e3 --- /dev/null +++ b/.github/workflows/pinact.yaml @@ -0,0 +1,30 @@ +name: Pinact + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + paths: + - ".github/workflows/**" + - ".github/actions/**" + +permissions: {} + +jobs: + pinact: + # Only run on pull requests from the same repository + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Pin actions + uses: suzuki-shunsuke/pinact-action@896d595f299e71d65b9d28349d6956abe144390a # v3.0.0 + with: + skip_push: true + verify: true + min_age: 7 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index f6e0177a..9638a832 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -4,24 +4,125 @@ on: release: types: [created] +permissions: {} + jobs: release: runs-on: ubuntu-latest + permissions: + contents: write # attach the SBOM to the GitHub release + outputs: + version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python 3.13 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: "3.13" - name: Install build requirements - run: python -m pip install wheel + run: python -m pip install uv - name: Build package - run: python setup.py sdist bdist_wheel + run: uv build + + - name: Read package version + id: version + run: | + set -euo pipefail + version=$(python - <<'PY' + import glob, zipfile + whl = glob.glob("dist/*.whl")[0] + with zipfile.ZipFile(whl) as z: + meta = next(n for n in z.namelist() if n.endswith(".dist-info/METADATA")) + for line in z.read(meta).decode().splitlines(): + if line.startswith("Version: "): + print(line.split(": ", 1)[1]) + break + PY + ) + echo "version=${version}" >> "$GITHUB_OUTPUT" + + # Install the built wheel into a clean env: the package plus the runtime + # dependencies users get today. Installing a wheel runs no repo code. + - name: Install runtime environment + run: | + uv venv .sbom-venv + uv pip install --python .sbom-venv dist/*.whl + + - uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + id: syft + + # Scan site-packages only; the venv's bin links to the interpreter outside it. + # SPDX for the release page, CycloneDX 1.6 for DependencyTrack. + - name: Generate SBOM + env: + SYFT: ${{ steps.syft.outputs.cmd }} + SYFT_FILE_METADATA_SELECTION: none + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + "$SYFT" dir:.sbom-venv/lib \ + --source-name commercetools \ + --source-version "${VERSION}" \ + -o spdx-json=commercetools.spdx.json \ + -o cyclonedx-json@1.6=commercetools.raw.cdx.json + # The package's own entry points at the local wheel (file://); drop it. + jq '.components |= map(if .externalReferences then .externalReferences |= map(select(.url | startswith("file://") | not)) else . end)' \ + commercetools.raw.cdx.json > commercetools.cdx.json - name: Publish package - uses: pypa/gh-action-pypi-publish@v1.8.14 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: user: __token__ password: ${{ secrets.pypi_password }} + + - name: Attach SBOM to release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "${TAG}" commercetools.spdx.json commercetools.cdx.json --clobber + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom-cyclonedx + path: commercetools.cdx.json + retention-days: 7 + + # Separate job so the DT key is never present while repo code runs (the build + # above executes setup.py). + dependencytrack: + needs: release + if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: {} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom-cyclonedx + + # Each release becomes a version under the "oss/python/commercetools" + # library container (OSS > OSS Python), which rolls up the latest version. + - name: Upload SBOM to DependencyTrack + env: + DT_API_KEY: ${{ secrets.DEPENDENCYTRACK_OSS_APIKEY }} + DT_URL: https://dependencytrack-api.labdigital.nl:8443 + PROJECT: oss/python/commercetools + VERSION: ${{ needs.release.outputs.version }} + run: | + set -euo pipefail + if [ -z "${DT_API_KEY}" ]; then + echo "::error::DEPENDENCYTRACK_OSS_APIKEY is not available to this repo" + exit 1 + fi + curl -sS --fail-with-body -X POST "${DT_URL}/api/v1/bom" \ + -H "X-Api-Key: ${DT_API_KEY}" \ + -F "autoCreate=true" \ + -F "projectName=${PROJECT}" \ + -F "projectVersion=${VERSION}" \ + -F "parentName=${PROJECT}" \ + -F "isLatest=true" \ + -F "bom=@commercetools.cdx.json" diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 8ad9841f..304367e9 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -8,18 +8,22 @@ on: # This should run the workflow for forked PRs - '**:**' +permissions: + contents: read jobs: format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Set up Python 3.13 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: "3.13" - name: Install dependencies run: pip install tox @@ -32,12 +36,14 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} @@ -52,26 +58,29 @@ jobs: - name: Prepare artifacts run: mkdir .coverage-data && mv .coverage.* .coverage-data/ - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-data-${{ matrix.python-version }} path: .coverage-data/ + include-hidden-files: true coverage: runs-on: ubuntu-latest needs: [test] steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: . pattern: coverage-data-* merge-multiple: true - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Set up Python 3.13 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.12" + python-version: "3.13" - name: Install dependencies run: | @@ -82,4 +91,4 @@ jobs: run: tox -e coverage-report - name: Upload to codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 diff --git a/.github/workflows/zizmor.yaml b/.github/workflows/zizmor.yaml new file mode 100644 index 00000000..abe8ccea --- /dev/null +++ b/.github/workflows/zizmor.yaml @@ -0,0 +1,32 @@ +name: Zizmor + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + paths: + - ".github/workflows/**" + - ".github/actions/**" + +permissions: {} + +jobs: + zizmor: + name: Run zizmor + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + advanced-security: false + annotations: true + min-severity: high diff --git a/CHANGES.md b/CHANGES.md index 7774393a..79f47d2f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,13 +1,18 @@ -24.4.6 ------- -- Regenerate code with latest RAML specifications, bringing the SDK up to date - again with new features released by commercetools. -- Remove ML client as sunsetted +26.10.12 +-------- - *BREAKING*: the SDK now returns `None` instead of raising an exception when a resource is not found. - *BREAKING*: the `scope` parameter in the `Client` class is now required. -- *BREAKING*: the old `commercetools.services.*` is removed, use the -- `commercetools.platform.*` instead. See release notes for `14.0.0` +- *BREAKING*: the old `commercetools.services.*` is removed, use + `commercetools.platform.*` instead. See release notes for `14.0.0`. +- *BREAKING*: requires Python 3.10 or newer; tested on 3.10–3.14. +- Pin marshmallow below 4. A fresh install with marshmallow 4 failed to import + (`cannot import name 'RAISE' from 'marshmallow.utils'`). +- Regenerate code with latest RAML specifications, bringing the SDK up to date + again with new features released by commercetools. +- Remove ML client as sunsetted. +- Fix the testing predicates on Python 3.14 (`ast.Str`/`ast.Num` were removed). +- Publish an SBOM (SPDX and CycloneDX) with each GitHub release. 23.6.29 -------- diff --git a/pyproject.toml b/pyproject.toml index d083849f..23945693 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=40.6.0", "wheel"] build-backend = "setuptools.build_meta" [tool.coverage.paths] -source = ["src", ".tox/*/site-packages"] +source = ["src", ".tox/*/site-packages", ".tox/*/lib/python*/site-packages"] [tool.isort] line_length = 88 diff --git a/setup.py b/setup.py index 299eba8b..c9a0834a 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,8 @@ "pytz", "requests-oauthlib>=1.0.0", "requests_mock>=1.8.0", - "marshmallow>=3.10.0", + # marshmallow 4 removed APIs the generated schemas use + "marshmallow>=3.10.0,<4", "marshmallow-enum>=1.5.1", "WebOb>=1.8.0", "Werkzeug>=0.15.2", @@ -21,12 +22,12 @@ ] tests_require = [ - "freezegun==1.2.0", + "freezegun==1.5.5", "mock==4.0.3", "pretend==1.0.9", "coverage[toml]>=6.2", - "pytest-cov==5.0.0", - "pytest==8.1.1", + "pytest-cov==7.1.0", + "pytest==9.0.3", # Linting "isort==5.10.1", "flake8==4.0.1", @@ -44,12 +45,13 @@ setup( name="commercetools", - version="2024.4.6", + version="2026.10.12", description="SDK for Commercetools", long_description=long_description, author="Lab Digital B.V.", author_email="opensource@labdigital.nl", url="https://github.com/labd/commercetools-python-sdk", + python_requires=">=3.10", install_requires=install_requires, tests_require=tests_require, extras_require={ @@ -65,11 +67,11 @@ "Development Status :: 5 - Production/Stable", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", ], zip_safe=False, diff --git a/src/commercetools/testing/predicates.py b/src/commercetools/testing/predicates.py index 33601d48..f50503bf 100644 --- a/src/commercetools/testing/predicates.py +++ b/src/commercetools/testing/predicates.py @@ -221,8 +221,10 @@ def ast(self, context: "Context"): func=ast.Name(id="filter_field", ctx=ast.Load()), args=[ ast.Name(id="obj", ctx=ast.Load()), - ast.List(elts=[ast.Str(s=i) for i in path], ctx=ast.Load()), - ast.Str(s=self.value), + ast.List( + elts=[ast.Constant(value=i) for i in path], ctx=ast.Load() + ), + ast.Constant(value=self.value), rhs, ], keywords=[], @@ -321,11 +323,11 @@ def nud(self): def ast(self, context: "Context"): if self.value.isdigit(): - return ast.Num(n=int(self.value)) + return ast.Constant(value=int(self.value)) if self.value in ["true", "false"]: - return ast.NameConstant(self.parse_bool(self.value)) + return ast.Constant(self.parse_bool(self.value)) else: - return ast.Str(s=self.value[1:-1]) + return ast.Constant(value=self.value[1:-1]) def parse_bool(self, value) -> bool: return value == "true" @@ -370,7 +372,7 @@ def nud(self): def ast(self, context: "Context"): node = self.first.ast(context) - node.elts.insert(0, ast.Str(s=self.value)) + node.elts.insert(0, ast.Constant(value=self.value)) return node diff --git a/src/commercetools/testing/products.py b/src/commercetools/testing/products.py index 42a26ad3..af5387e4 100644 --- a/src/commercetools/testing/products.py +++ b/src/commercetools/testing/products.py @@ -440,7 +440,7 @@ def urls(self): ("^(?P[^/]+)$", "GET", self.get_by_id), ("^(?P[^/]+)$", "POST", self.update_by_id), ("^(?P[^/]+)$", "DELETE", self.delete_by_id), - ("^(?P[^/]+)\/images$", "POST", self.upload_image), + ("^(?P[^/]+)/images$", "POST", self.upload_image), ] # Fixme: use decorator for this diff --git a/tests/platform/test_client.py b/tests/platform/test_client.py index 392ef9b1..bc34461d 100644 --- a/tests/platform/test_client.py +++ b/tests/platform/test_client.py @@ -40,7 +40,8 @@ def test_auto_refresh(commercetools_api): token_url="https://auth.europe-west1.gcp.commercetools.com/oauth/token", ).with_project_key("unittest") client.products().get() - time.sleep(1) + # Margin past the 1s expiry; exactly 1s made this test flaky + time.sleep(1.5) client.products().get() client.products().get() diff --git a/tox.ini b/tox.ini index 3854a971..43d4ff12 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,20 @@ [tox] -envlist = py36,py37,py38,py39,py310 +envlist = py310,py311,py312,py313,py314 [gh-actions] python = - 3.6: py36 - 3.7: py37 - 3.8: py38 - 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + 3.14: py314 [testenv] commands = coverage run --source commercetools --parallel -m pytest {posargs} extras = test [testenv:coverage-report] -basepython = python3.10 +basepython = python3.13 deps = coverage[toml] skip_install = true commands = @@ -23,7 +23,7 @@ commands = coverage report [testenv:format] -basepython = python3.10 +basepython = python3.13 deps = black==24.3.0 isort[toml]==5.10.1