Skip to content

Commit 953067d

Browse files
Implement bulk upload releases (#1187)
* Update pre-commit configuration and clean up Dockerfiles and workflows - Added new hooks to the pre-commit configuration for better code quality checks. - Cleaned up whitespace in Dockerfiles and docker-compose.yml for consistency. - Removed unnecessary blank lines in GitHub Actions workflows to enhance readability. * Update caniuse-lite dependency in package-lock.json to version 1.0.30001720, ensuring compatibility with the latest features and improvements. * Refactor upload validation tests to use mocker for improved readability and consistency. Updated test functions to replace monkeypatching with mocker.patch, enhancing the clarity of mock setups and ensuring better integration with pytest's mocking capabilities. * Refactor SCSS files to use `@use` instead of `@import` for improved modularity and maintainability. Updated color adjustments to utilize `color.adjust` for better control over color lightness in hover states. * Add bulk release functionality for uploads - Introduced BulkReleaseForm to handle bulk release submissions. - Implemented BulkReleaseView to manage the release of multiple uploads at once, including validation and error handling. - Added a new bulk release template for user interaction. - Updated project detail view to link to the bulk release feature when multiple uploads are present. - Created comprehensive tests for bulk release functionality, ensuring robust validation and release processes. * Refactor mock_user fixture to utilize mocker for improved testing consistency. This change enhances the clarity of mock setups in the test suite. * Refactor BulkReleaseView to inherit from ProjectMixin and MethodView - Updated the inheritance structure of BulkReleaseView to improve functionality and maintainability. - Adjusted tests to verify the new inheritance and decorators, ensuring proper permission checks and method availability. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Enhance bulk release tests for improved clarity and coverage - Refactored test functions in test_bulk_release.py to utilize consistent mocking practices, improving readability and maintainability. - Added comprehensive tests for various scenarios in the bulk release process, including form validation, release success, and error handling. - Updated test_upload_validation.py to streamline HTTP error handling and ensure proper warning treatment for 404 errors. - Ensured all tests are integrated with Flask context for better coverage and reliability. * Formatting. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent cd76b2d commit 953067d

17 files changed

+1101
-126
lines changed

.github/workflows/cleanup-cache.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
cleanup:
1515
runs-on: ubuntu-latest
1616
if: github.repository_owner == 'jazzband'
17-
17+
1818
steps:
1919
- name: Log in to Container Registry
2020
uses: docker/login-action@v3
@@ -27,13 +27,13 @@ jobs:
2727
run: |
2828
# Get all image versions
2929
PACKAGE_NAME=$(echo "${{ env.BASE_IMAGE_NAME }}" | cut -d'/' -f2-)
30-
30+
3131
# Get all versions of the base image package
3232
VERSIONS=$(gh api \
3333
-H "Accept: application/vnd.github.v3+json" \
3434
"/orgs/${{ github.repository_owner }}/packages/container/$PACKAGE_NAME/versions" \
3535
--jq '.[].id' | head -n -5) # Keep the 5 most recent versions
36-
36+
3737
# Delete old versions (keep the latest 5)
3838
for version_id in $VERSIONS; do
3939
echo "Deleting version ID: $version_id"
@@ -43,4 +43,4 @@ jobs:
4343
"/orgs/${{ github.repository_owner }}/packages/container/$PACKAGE_NAME/versions/$version_id" || true
4444
done
4545
env:
46-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ jobs:
137137
# Create coverage output directory
138138
mkdir -p $PWD/coverage-data
139139
chmod 777 $PWD/coverage-data
140-
140+
141141
# Run tests with volume mount for coverage output
142142
docker compose run --rm \
143143
-e COVERAGE_FILE \

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
repos:
2+
- repo: https://github.com/pre-commit/pre-commit-hooks
3+
rev: v5.0.0
4+
hooks:
5+
- id: trailing-whitespace
6+
- id: check-yaml
7+
- id: check-added-large-files
8+
- id: check-merge-conflict
29
- repo: https://github.com/astral-sh/ruff-pre-commit
310
rev: v0.6.5
411
hooks:

Dockerfile.app

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ USER 10001
3535

3636
EXPOSE 5000
3737

38-
ENTRYPOINT ["/app/docker-entrypoint.sh", "--"]
38+
ENTRYPOINT ["/app/docker-entrypoint.sh", "--"]

Dockerfile.base

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,4 @@ LABEL deps_hash=$DEPS_HASH
6060

6161
RUN chown -R 10001:10001 /app
6262

63-
USER 10001
63+
USER 10001

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ services:
1313
platform: linux/amd64
1414
# Use JAZZBAND_IMAGE if set (for CI), otherwise build locally
1515
image: ${JAZZBAND_IMAGE:-}
16-
build:
16+
build:
1717
context: .
1818
dockerfile: ${DOCKERFILE:-Dockerfile}
1919
volumes:

jazzband/projects/forms.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,5 +145,16 @@ def add_global_error(self, *messages):
145145
self.global_errors.extend(messages)
146146

147147

148+
class BulkReleaseForm(TwoFactorAuthValidation, ProjectNameForm):
149+
submit = SubmitField("Bulk Release")
150+
151+
def __init__(self, *args, **kwargs):
152+
self.global_errors = []
153+
super().__init__(*args, **kwargs)
154+
155+
def add_global_error(self, *messages):
156+
self.global_errors.extend(messages)
157+
158+
148159
class DeleteForm(TwoFactorAuthValidation, ProjectNameForm):
149160
submit = SubmitField("Delete")

jazzband/projects/views.py

Lines changed: 185 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
from packaging.utils import canonicalize_name as safe_name
2727
from packaging.version import parse as parse_version
2828
import requests
29-
from requests.exceptions import HTTPError
3029
from sqlalchemy import desc, nullsfirst, nullslast
3130
from sqlalchemy.sql.expression import func
3231
from werkzeug.security import safe_join
@@ -39,7 +38,7 @@
3938
from ..exceptions import eject
4039
from ..members.decorators import member_required
4140
from ..tasks import spinach
42-
from .forms import DeleteForm, ReleaseForm, UploadForm
41+
from .forms import BulkReleaseForm, DeleteForm, ReleaseForm, UploadForm
4342
from .models import Project, ProjectMembership, ProjectUpload
4443
from .tasks import send_new_upload_notifications, update_upload_ordering
4544

@@ -469,8 +468,8 @@ def validate_upload(self, timeout=10):
469468
if e.response.status_code == 404:
470469
# File not yet available - likely CDN delay
471470
warning = (
472-
f"File not yet visible on PyPI API. This is likely due to CDN "
473-
f"propagation delays and should resolve shortly."
471+
"File not yet visible on PyPI API. This is likely due to CDN "
472+
"propagation delays and should resolve shortly."
474473
)
475474
logger.warning(warning)
476475
warnings.append(warning)
@@ -724,6 +723,184 @@ def post(self, name, upload_id):
724723
return context
725724

726725

726+
class BulkReleaseView(ProjectMixin, MethodView):
727+
"""
728+
A view to release all uploads of a given version at once.
729+
"""
730+
731+
methods = ["GET", "POST"]
732+
decorators = [login_required, templated()]
733+
734+
def project_query(self, name):
735+
"""Override to include authorization logic for project leads."""
736+
projects = super().project_query(name)
737+
if current_user_is_roadie():
738+
return projects
739+
return projects.filter(
740+
Project.membership.any(user=current_user, is_lead=True),
741+
)
742+
743+
def get_unreleased_uploads_for_version(self, version):
744+
"""Get all unreleased uploads for a specific version."""
745+
return self.project.uploads.filter_by(version=version, released_at=None).all()
746+
747+
def validate_uploads_bulk(self, uploads, timeout=10):
748+
"""
749+
Validate multiple uploads at once.
750+
Returns (overall_success, all_errors, all_warnings) tuple.
751+
"""
752+
all_errors = []
753+
all_warnings = []
754+
755+
for upload in uploads:
756+
# Create a temporary UploadReleaseView to use its validation logic
757+
temp_view = UploadReleaseView()
758+
temp_view.upload = upload
759+
temp_view.project = self.project
760+
761+
success, errors, warnings = temp_view.validate_upload(timeout)
762+
763+
# Prefix errors/warnings with filename for clarity
764+
for error in errors:
765+
all_errors.append(f"{upload.filename}: {error}")
766+
for warning in warnings:
767+
all_warnings.append(f"{upload.filename}: {warning}")
768+
769+
# Overall success if no blocking errors
770+
return len(all_errors) == 0, all_errors, all_warnings
771+
772+
def release_uploads_bulk(self, uploads):
773+
"""
774+
Release multiple uploads using twine.
775+
Returns (success, twine_outputs) tuple.
776+
"""
777+
twine_outputs = []
778+
779+
with tempfile.TemporaryDirectory() as tmpdir:
780+
# Copy all files to temp directory
781+
upload_paths = []
782+
for upload in uploads:
783+
upload_path = os.path.join(tmpdir, upload.filename)
784+
shutil.copy(upload.full_path, upload_path)
785+
upload_paths.append(upload_path)
786+
787+
# Run twine upload on all files at once
788+
files_arg = " ".join(f'"{path}"' for path in upload_paths)
789+
twine_run = delegator.run(f"twine upload {files_arg}")
790+
twine_outputs.append(twine_run)
791+
792+
return twine_run.return_code == 0, twine_outputs
793+
794+
def get(self, name, version):
795+
if not current_app.config["RELEASE_ENABLED"]:
796+
message = "Releasing is currently out of service"
797+
flash(message)
798+
return self.redirect_to_project()
799+
800+
uploads = self.get_unreleased_uploads_for_version(version)
801+
802+
if not uploads:
803+
flash(f"No unreleased uploads found for version {version}")
804+
return self.redirect_to_project()
805+
806+
# Check if any uploads are already released
807+
released_uploads = (
808+
self.project.uploads.filter_by(version=version)
809+
.filter(ProjectUpload.released_at.isnot(None))
810+
.all()
811+
)
812+
813+
bulk_release_form = BulkReleaseForm(project_name=self.project.name)
814+
815+
return {
816+
"project": self.project,
817+
"version": version,
818+
"uploads": uploads,
819+
"released_uploads": released_uploads,
820+
"bulk_release_form": bulk_release_form,
821+
}
822+
823+
def post(self, name, version):
824+
if not current_app.config["RELEASE_ENABLED"]:
825+
message = "Releasing is currently out of service"
826+
flash(message)
827+
return self.redirect_to_project()
828+
829+
uploads = self.get_unreleased_uploads_for_version(version)
830+
831+
if not uploads:
832+
flash(f"No unreleased uploads found for version {version}")
833+
return self.redirect_to_project()
834+
835+
bulk_release_form = BulkReleaseForm(project_name=self.project.name)
836+
837+
context = {
838+
"project": self.project,
839+
"version": version,
840+
"uploads": uploads,
841+
"bulk_release_form": bulk_release_form,
842+
}
843+
844+
if bulk_release_form.validate_on_submit():
845+
# Validate all uploads first
846+
validation_success, all_errors, all_warnings = self.validate_uploads_bulk(
847+
uploads
848+
)
849+
850+
# Add validation errors to form (these will block release)
851+
if all_errors:
852+
bulk_release_form.add_global_error(*all_errors)
853+
854+
# Show warnings as flash messages (these won't block)
855+
for warning in all_warnings:
856+
flash(warning, category="warning")
857+
858+
# Proceed with bulk release if no blocking errors
859+
if not all_errors:
860+
# Release all uploads at once
861+
release_success, twine_outputs = self.release_uploads_bulk(uploads)
862+
863+
if release_success:
864+
# Mark all uploads as released
865+
release_time = datetime.utcnow()
866+
released_count = 0
867+
868+
for upload in uploads:
869+
upload.released_at = release_time
870+
upload.save()
871+
released_count += 1
872+
873+
if all_warnings:
874+
message = (
875+
f"Successfully released {released_count} uploads for version {version} to PyPI. "
876+
f"Note: Some validation warnings were encountered (see above)."
877+
)
878+
else:
879+
message = f"Successfully released {released_count} uploads for version {version} to PyPI."
880+
881+
flash(message, category="success")
882+
logger.info(
883+
f"Bulk release successful: {released_count} uploads for {self.project.name} v{version}"
884+
)
885+
return self.redirect_to_project()
886+
else:
887+
error = f"Bulk release of version {version} failed."
888+
bulk_release_form.add_global_error(error)
889+
logger.error(
890+
error,
891+
extra={
892+
"data": {
893+
"twine_outputs": [
894+
output.out for output in twine_outputs
895+
]
896+
}
897+
},
898+
)
899+
context.update({"twine_outputs": twine_outputs})
900+
901+
return context
902+
903+
727904
# /projects/test-project/1/delete
728905
projects.add_url_rule(
729906
"/<name>/upload/<upload_id>/delete", view_func=UploadDeleteView.as_view("delete")
@@ -742,6 +919,10 @@ def post(self, name, upload_id):
742919
projects.add_url_rule(
743920
"/<name>/upload/<upload_id>/release", view_func=UploadReleaseView.as_view("release")
744921
)
922+
# /projects/test-project/release/1.0.0
923+
projects.add_url_rule(
924+
"/<name>/release/<version>", view_func=BulkReleaseView.as_view("bulk_release")
925+
)
745926
# /projects/test-project/join
746927
projects.add_url_rule("/<name>/join", view_func=JoinView.as_view("join"))
747928
# /projects/test-project/leave

0 commit comments

Comments
 (0)