From 9dfc1bb7e69d329a72c5d5552bb9ecaa84095903 Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Tue, 20 Jan 2026 19:06:41 +1000 Subject: [PATCH 1/3] chore: improve test workflow with staged jobs and Django matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split workflow into stages: lint → test → ci-success - Add typecheck job with mypy (non-blocking) - Add Python × Django version matrix (3.11+4.2, 3.12+5.0, 3.13+5.1) - Add concurrency control to cancel duplicate runs - Add ci-success final gate to verify all jobs passed - Add mypy and django-stubs to dev dependencies --- .github/workflows/test.yml | 117 +++++++++++++++++++++++++++++++++---- pyproject.toml | 13 +++++ 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 432d1a3..96fdb56 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,34 +2,127 @@ name: Test on: push: - branches: - - master + branches: [master] pull_request: + branches: [master] types: [opened, synchronize, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Check formatting + run: uv run ruff format --check src/ tests/ + + - name: Check linting + run: uv run ruff check src/ tests/ + + typecheck: + name: Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run mypy + run: uv run mypy src/pyssertive + continue-on-error: true + test: + name: Test (py${{ matrix.python-version }}, dj${{ matrix.django-version }}) runs-on: ubuntu-latest + needs: [lint] + strategy: + fail-fast: false matrix: - python-version: ['3.11', '3.12', '3.13'] + include: + - python-version: "3.11" + django-version: "4.2" + - python-version: "3.12" + django-version: "5.0" + - python-version: "3.13" + django-version: "5.1" steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install uv - uses: astral-sh/setup-uv@v5 - - name: Install dependencies - run: uv sync --all-extras + run: | + uv sync --all-extras + uv pip install "Django~=${{ matrix.django-version }}.0" - - name: Lint - run: uv run ruff check src/ tests/ + - name: Run tests + run: uv run pytest --cov=pyssertive --cov-report=term-missing --cov-fail-under=100 + + ci-success: + name: CI Success + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + if: always() + + steps: + - name: Check all jobs + run: | + echo "===== Job Results =====" + echo "Lint: ${{ needs.lint.result }}" + echo "Typecheck: ${{ needs.typecheck.result }}" + echo "Test: ${{ needs.test.result }}" + echo "=======================" + + if [ "${{ needs.lint.result }}" != "success" ]; then + echo "❌ Lint failed" + exit 1 + fi + + if [ "${{ needs.test.result }}" != "success" ]; then + echo "❌ Tests failed" + exit 1 + fi + + if [ "${{ needs.typecheck.result }}" == "failure" ]; then + echo "⚠️ Typecheck failed (non-blocking)" + fi - - name: Test - run: uv run pytest + echo "✅ All required checks passed!" diff --git a/pyproject.toml b/pyproject.toml index 417f0ab..0e77467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ dev = [ "pytest-django>=4.8.0", "pytest-cov>=6.0.0", "ruff>=0.9.0", + "mypy>=1.0.0", + "django-stubs>=5.0.0", ] [tool.ruff] @@ -83,3 +85,14 @@ exclude_lines = [ "pragma: no branch", "if TYPE_CHECKING:", ] + +[tool.mypy] +python_version = "3.11" +plugins = ["mypy_django_plugin.main"] +strict = false +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = false + +[tool.django-stubs] +django_settings_module = "tests.app.settings" From 42d9b1477291e5714b6f8ccc8acfbe26efd9929a Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Tue, 20 Jan 2026 19:29:04 +1000 Subject: [PATCH 2/3] chore: add improved release workflow with trusted publishing --- .github/workflows/publish.yml | 55 ---------- .github/workflows/release.yml | 192 ++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+), 55 deletions(-) delete mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 40007ce..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Publish - -on: - push: - tags: - - '*' - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Install dependencies - run: uv sync --all-extras - - - name: Lint - run: uv run ruff check src/ tests/ - - - name: Test - run: uv run pytest - - - name: Get tag version - id: tag - uses: actions/github-script@v7 - with: - result-encoding: string - script: | - return context.ref.replace('refs/tags/', '') - - - name: Update version - run: | - sed -i 's/version = ".*"/version = "${{ steps.tag.outputs.result }}"/' pyproject.toml - - - name: Build package - run: uv build - - - name: Publish to PyPI - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - uv pip install twine - uv run twine upload dist/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0409fb3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,192 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + + workflow_dispatch: + inputs: + confirm_master: + description: "Type 'master' to confirm release from master branch" + required: true + type: string + +jobs: + validate: + name: Validate Release + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate Branch (Tag Push) + if: github.event_name == 'push' + run: | + TAG_COMMIT=$(git rev-list -n 1 ${{ github.ref_name }}) + + if ! git merge-base --is-ancestor $TAG_COMMIT origin/master; then + echo "❌ ERROR: Tag ${{ github.ref_name }} is not on master branch" + echo "Tags must be created from commits that are on master." + exit 1 + fi + + echo "✅ Tag ${{ github.ref_name }} is on master branch" + + - name: Validate Branch (Manual Dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + if [ "${{ github.ref_name }}" != "master" ]; then + echo "❌ ERROR: Manual release must be run from master branch" + echo "Current branch: ${{ github.ref_name }}" + exit 1 + fi + + if [ "${{ inputs.confirm_master }}" != "master" ]; then + echo "❌ ERROR: Confirmation failed" + echo "You must type 'master' to confirm release" + exit 1 + fi + + echo "✅ Manual release confirmed on master branch" + + - name: Extract Version + id: version + run: | + if [ "${{ github.event_name }}" == "push" ]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + else + VERSION=$(grep -oP '^version = "\K[^"]+' pyproject.toml) + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "📦 Version: $VERSION" + + - name: Validate Version Format + run: | + VERSION="${{ steps.version.outputs.version }}" + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then + echo "❌ ERROR: Invalid version format: $VERSION" + echo "Expected: X.Y.Z or X.Y.Z-suffix" + exit 1 + fi + echo "✅ Version format valid: $VERSION" + + build: + name: Build Package + runs-on: ubuntu-latest + needs: [validate] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Update Version + run: | + sed -i 's/version = ".*"/version = "${{ needs.validate.outputs.version }}"/' pyproject.toml + + - name: Build Package + run: uv build + + - name: Verify Package + run: | + echo "📦 Built packages:" + ls -la dist/ + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 5 + + test-install: + name: Test Install (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + needs: [build] + + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Install Package + run: | + pip install dist/*.whl + python -c "from pyssertive.http import FluentHttpAssertClient; print('✅ pyssertive installed successfully')" + + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: [validate, build, test-install] + environment: pypi + + permissions: + id-token: write + + steps: + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + print-hash: true + + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + needs: [validate, publish-pypi] + if: github.event_name == 'push' + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: Release ${{ needs.validate.outputs.version }} + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + generate_release_notes: true + files: dist/* From 15c92f62fe6c7c36178c893d52a8f905a42103a8 Mon Sep 17 00:00:00 2001 From: Unay Santisteban Date: Tue, 20 Jan 2026 19:39:01 +1000 Subject: [PATCH 3/3] doc: add streaming response and file download assertions to README --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 1c60785..ef65b0a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Fluent, chainable assertions for Django tests. Inspired by Laravel's elegant tes - Form and formset error assertions - Session and cookie assertions - Header assertions +- Streaming response and file download assertions - Debug helpers for test development ## Requirements @@ -89,6 +90,21 @@ response.assert_template_used("users/list.html")\ .assert_context_equals("page", 1) ``` +### Streaming and Download Assertions + +```python +response.assert_streaming()\ + .assert_download("report.csv")\ + .assert_streaming_contains("Expected content")\ + .assert_streaming_not_contains("Sensitive data")\ + .assert_streaming_matches(r"ID:\d+")\ + .assert_streaming_line_count(exact=10)\ + .assert_streaming_line_count(min=5, max=20)\ + .assert_streaming_csv_header(["id", "name", "email"])\ + .assert_streaming_line(0, "header,row")\ + .assert_streaming_empty() +``` + ### Debug Helpers ```python