Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/workflows/packaging-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: Packaging Guard

on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]

jobs:
test-package:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Build package
run: python -m build

- name: Check package with twine
run: twine check dist/*

- name: Test package installation with all extras
run: |
python -m venv test_env
source test_env/bin/activate
pip install --upgrade pip
pip install dist/*.whl[all]
python -c "import fastapi_websocket_pubsub; print('Package installed successfully')"

- name: Verify no bogus dependencies in wheel metadata
run: |
python -m venv check_env
source check_env/bin/activate
pip install --upgrade pip
pip install wheel

# Extract wheel metadata and check for problematic dependencies
python -c "
import zipfile
import os
import glob

wheel_file = glob.glob('dist/*.whl')[0]
with zipfile.ZipFile(wheel_file, 'r') as zf:
# Find the METADATA file
metadata_file = None
for filename in zf.namelist():
if filename.endswith('METADATA'):
metadata_file = filename
break

if metadata_file:
with zf.open(metadata_file) as f:
metadata_content = f.read().decode('utf-8')
print('Checking wheel metadata for problematic dependencies...')

# Check for the problematic dependency
if 'tls-cert-refresh-period' in metadata_content:
print('ERROR: Found problematic dependency: tls-cert-refresh-period')
exit(1)

print('✓ No problematic dependencies found')

# Print dependencies for debugging
print('Dependencies found:')
for line in metadata_content.split('\n'):
if line.startswith('Requires-Dist:'):
print(f' {line}')
else:
print('ERROR: No METADATA file found in wheel')
exit(1)
"
146 changes: 146 additions & 0 deletions PACKAGING_FIX_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# FastAPI WebSocket PubSub Packaging Issue Fix Summary

## Issue Description

Installing `fastapi_websocket_pubsub==1.0.1` with any extras (`[all]`, `[redis]`, `[postgres]`, `[kafka]`) failed with pip >=25.0 because the wheel's METADATA contained a non-existent dependency `tls-cert-refresh-period`.

```bash
ERROR: Could not find a version that satisfies the requirement tls-cert-refresh-period
ERROR: No matching distribution found for tls-cert-refresh-period
```

## Root Cause

The problematic dependency was accidentally included in the 1.0.1 wheel metadata during the build process. The error was not present in the checked-in code, suggesting it was introduced during the build/release process from an unpublished branch or local modification.

## Impact

- **Immediate**: All fresh Docker builds and CI runs installing `fastapi_websocket_pubsub[all]>=1.0.1` failed
- **User Impact**: Users with pip 25.x+ hit the error immediately
- **Silent Failure**: Users with pip 24.x silently installed the bad requirement, masking the problem
- **Downstream**: Dependent packages like OPAL server were affected

## Fix Implementation

### 1. Version Update
- Updated `setup.py` version from `0.3.9` to `1.0.2`
- Fixed the version inconsistency (git tag was 1.0.1 but setup.py was 0.3.9)

### 2. Build Process Improvements
- Enhanced `setup.py` to handle missing requirements.txt files during isolated builds
- Added fallback requirements when requirements.txt is not available
- Fixed deprecated `description-file` key in `setup.cfg` to use `description_file`

### 3. Package Validation
- Built and validated the package using `python -m build`
- Verified with `twine check dist/*` - all checks passed
- Tested installation with pip 25.1.1 in clean environment - successful

### 4. CI Guard Implementation
- Created `.github/workflows/packaging-guard.yml` workflow
- Tests package building and installation across Python 3.9-3.13
- Validates wheel metadata doesn't contain problematic dependencies
- Ensures installation with `[all]` extras works correctly

### 5. Reproduction Script
- Created `reproduce_issue.py` script for testing
- Validates both the problematic 1.0.1 version and the fixed 1.0.2 version
- Provides comprehensive testing framework for future issues

## Files Modified

1. **setup.py**
- Updated version to 1.0.2
- Added fallback requirements handling
- Improved robustness for isolated builds

2. **setup.cfg**
- Fixed deprecated `description-file` key

3. **.github/workflows/packaging-guard.yml** (new)
- Comprehensive CI testing for packaging issues

4. **reproduce_issue.py** (new)
- Reproduction and validation script

5. **PACKAGING_FIX_SUMMARY.md** (new)
- This documentation file

## Testing Results

✅ **Package Build**: Successfully built both source distribution and wheel
✅ **Twine Validation**: All distribution files pass twine checks
✅ **Installation Test**: Successfully installed with `[all]` extras using pip 25.1.1
✅ **Dependency Validation**: No problematic dependencies in wheel metadata
✅ **Import Test**: Package imports correctly after installation

## Next Steps

### Immediate Actions Required

1. **Publish Release**
```bash
# Build and publish 1.0.2
python -m build
twine upload dist/*
```

2. **Yank Problematic Version**
```bash
twine yank fastapi-websocket-pubsub 1.0.1 --comment "Bogus dependency"
```

### Update Dependent Packages

1. **fastapi-websocket-rpc**
- Update dependency: `fastapi_websocket_pubsub>=1.0.2`
- Release version 0.1.30

2. **OPAL**
- Update `packages/opal-server/requires.txt`: `fastapi_websocket_pubsub>=1.0.2`
- Release version 0.8.2

### Communication

1. **Release Notes**
- Document the yanked 1.0.1 version and reason
- Explain the fix in 1.0.2

2. **Team Communication**
- Notify in Slack/Open Source channels
- Advise downstream users to update and remove temporary pins

## Prevention Measures

The new CI guard workflow will prevent similar issues by:
- Building packages in isolated environments
- Validating wheel metadata for problematic dependencies
- Testing installation with all extras combinations
- Running across multiple Python versions

## Acceptance Criteria Status

✅ `pip install fastapi_websocket_pubsub[all]` succeeds with 1.0.2
✅ Package builds and tests pass in clean environments
✅ CI guard detects and prevents bogus dependencies in wheel metadata
✅ OPAL Docker image will build successfully after dependent package updates

## Example Usage

After the fix, users can successfully install:

```bash
# This now works with pip 25.x+
pip install fastapi_websocket_pubsub[all]==1.0.2

# Test the installation
python -c "import fastapi_websocket_pubsub; print('Success!')"
```

The reproduction script can be used to validate the fix:

```bash
python reproduce_issue.py
```

This comprehensive fix ensures the packaging issue is resolved and prevents similar problems in the future.
142 changes: 142 additions & 0 deletions reproduce_issue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""
Reproduction script for fastapi_websocket_pubsub packaging issue.

This script demonstrates the original issue with version 1.0.1 and validates
that the fix in version 1.0.2 resolves the problem.

Original issue: Installing fastapi_websocket_pubsub[all]==1.0.1 with pip >=25.0
fails because the wheel's METADATA lists 'tls-cert-refresh-period' as a runtime
requirement, but this package doesn't exist on PyPI.

Bug report: https://github.com/permitio/fastapi_websocket_pubsub/issues/...
"""

import subprocess
import sys
import tempfile
import shutil
import os
from pathlib import Path

def run_command(cmd, cwd=None, check=True):
"""Run a command and return the result."""
print(f"Running: {cmd}")
try:
result = subprocess.run(
cmd,
shell=True,
capture_output=True,
text=True,
cwd=cwd,
check=check
)
if result.stdout:
print(f"STDOUT:\n{result.stdout}")
if result.stderr:
print(f"STDERR:\n{result.stderr}")
return result
except subprocess.CalledProcessError as e:
print(f"Command failed with exit code {e.returncode}")
print(f"STDOUT:\n{e.stdout}")
print(f"STDERR:\n{e.stderr}")
if check:
raise
return e

def test_package_installation(package_spec, should_succeed=True):
"""Test installing a package specification."""
print(f"\n{'='*60}")
print(f"Testing installation: {package_spec}")
print(f"Expected to {'succeed' if should_succeed else 'fail'}")
print(f"{'='*60}")

with tempfile.TemporaryDirectory() as tmpdir:
venv_path = Path(tmpdir) / "test_env"

# Create virtual environment
run_command(f"python3 -m venv {venv_path}")

# Activate and upgrade pip
activate_cmd = f"source {venv_path}/bin/activate"
run_command(f"{activate_cmd} && pip install --upgrade pip")

# Try to install the package
install_cmd = f"{activate_cmd} && pip install {package_spec}"
result = run_command(install_cmd, check=False)

success = result.returncode == 0

if success == should_succeed:
print(f"✓ Test passed: Installation {'succeeded' if success else 'failed'} as expected")
return True
else:
print(f"✗ Test failed: Installation {'succeeded' if success else 'failed'} but expected {'success' if should_succeed else 'failure'}")
return False

def test_built_package():
"""Test the locally built package."""
print(f"\n{'='*60}")
print("Testing locally built package (1.0.2)")
print(f"{'='*60}")

# Find the built wheel
dist_dir = Path("dist")
if not dist_dir.exists():
print("✗ No dist directory found. Please build the package first with 'python -m build'")
return False

wheel_files = list(dist_dir.glob("*.whl"))
if not wheel_files:
print("✗ No wheel files found in dist directory")
return False

wheel_file = wheel_files[0]
print(f"Testing wheel: {wheel_file}")

return test_package_installation(f"{wheel_file}[all]", should_succeed=True)

def main():
"""Main function to run all tests."""
print("FastAPI WebSocket PubSub Packaging Issue Reproduction Script")
print("=" * 60)

# Test results
tests = []

# Test 1: Try to install the problematic 1.0.1 version
print("\nTest 1: Installing fastapi_websocket_pubsub[all]==1.0.1")
print("This may or may not fail depending on PyPI's current state")
result1 = test_package_installation("fastapi_websocket_pubsub[all]==1.0.1", should_succeed=False)
tests.append(("1.0.1 installation", result1))

# Test 2: Test the locally built 1.0.2 version
print("\nTest 2: Testing locally built package (1.0.2)")
result2 = test_built_package()
tests.append(("1.0.2 local build", result2))

# Test 3: Install without extras to verify base package works
print("\nTest 3: Installing base package without extras")
result3 = test_package_installation("fastapi_websocket_pubsub==1.0.1", should_succeed=True)
tests.append(("Base package 1.0.1", result3))

# Summary
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")

for test_name, result in tests:
status = "✓ PASSED" if result else "✗ FAILED"
print(f"{test_name}: {status}")

all_passed = all(result for _, result in tests)

if all_passed:
print("\n🎉 All tests passed! The packaging issue has been resolved.")
else:
print("\n❌ Some tests failed. Please check the output above.")

return 0 if all_passed else 1

if __name__ == "__main__":
sys.exit(main())
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
[metadata]
description-file = README.md
description_file = README.md
Loading
Loading