This is an automated email from the ASF dual-hosted git repository. sbp pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/tooling-atr-experiments.git
The following commit(s) were added to refs/heads/main by this push: new 77258a9 Ensure that podlings are added to the database and displayed 77258a9 is described below commit 77258a90d996278dcf2e2cf26aa2c544a30288da Author: Sean B. Palmer <s...@miscoranda.com> AuthorDate: Thu Feb 20 17:27:52 2025 +0200 Ensure that podlings are added to the database and displayed --- atr/blueprints/secret/secret.py | 86 ++++++++++--- atr/db/models.py | 9 ++ atr/routes.py | 141 ++++++++++++--------- atr/templates/candidate-attach.html | 9 +- atr/templates/candidate-create.html | 9 +- atr/templates/candidate-review.html | 2 +- atr/templates/candidate-signature-verify.html | 2 +- atr/templates/pmc-directory.html | 10 +- atr/templates/secret/update-pmcs.html | 19 ++- ...al_schema.py => d3ff8c13bb8b_initial_schema.py} | 6 +- 10 files changed, 197 insertions(+), 96 deletions(-) diff --git a/atr/blueprints/secret/secret.py b/atr/blueprints/secret/secret.py index c17f599..ae7b791 100644 --- a/atr/blueprints/secret/secret.py +++ b/atr/blueprints/secret/secret.py @@ -26,7 +26,7 @@ from werkzeug.wrappers.response import Response from asfquart.base import ASFQuartException from asfquart.session import read as session_read -from atr.apache import get_apache_project_data +from atr.apache import ApacheProjects, get_apache_project_data from atr.db import get_session from atr.db.models import ( PMC, @@ -45,6 +45,11 @@ from atr.db.service import get_pmcs from . import blueprint _WHIMSY_COMMITTEE_URL = "https://whimsy.apache.org/public/committee-info.json" +_PROJECT_PODLINGS_URL = "https://projects.apache.org/json/foundation/podlings.json" +_PROJECT_GROUPS_URL = "https://projects.apache.org/json/foundation/groups.json" + + +class FlashError(RuntimeError): ... @blueprint.route("/data") @@ -96,14 +101,12 @@ async def secret_data(model: str = "PMC") -> str: @blueprint.route("/pmcs/update", methods=["GET", "POST"]) async def secret_pmcs_update() -> str | Response: - """Update PMCs from remote, authoritative committee-info.json.""" - + """Update PMCs and podlings from remote data.""" if request.method == "POST": - # Fetch committee-info.json from Whimsy try: - apache_projects = await get_apache_project_data() - except (httpx.RequestError, json.JSONDecodeError) as e: - await flash(f"Failed to fetch committee data: {e!s}", "error") + apache_projects, podlings_data, groups_data = await secret_pmcs_update_data() + except FlashError as e: + await flash(f"{e!s}", "error") return redirect(url_for("secret_blueprint.secret_pmcs_update")) updated_count = 0 @@ -111,6 +114,7 @@ async def secret_pmcs_update() -> str | Response: try: async with get_session() as db_session: async with db_session.begin(): + # First update PMCs for project in apache_projects.projects: name = project.name # Skip non-PMC committees @@ -124,14 +128,32 @@ async def secret_pmcs_update() -> str | Response: pmc = PMC(project_name=name) db_session.add(pmc) - # Update PMC data - pmc.pmc_members = project.owners - pmc.committers = project.members + # Update PMC data from groups.json + pmc.pmc_members = groups_data.get(f"{name}-pmc", []) + pmc.committers = groups_data.get(name, []) + pmc.is_podling = False # Ensure this is set for PMCs + + # For release managers, use PMC members for now + # TODO: Consider a more sophisticated way to determine release managers + pmc.release_managers = pmc.pmc_members - # Mark chairs as release managers - # TODO: Who else is a release manager? How do we know? - # lets assume for now that all owners are also release managers - pmc.release_managers = project.owners + updated_count += 1 + + # Then add PPMCs (podlings) + for podling_name, podling_data in podlings_data.items(): + # Get or create PPMC + statement = select(PMC).where(PMC.project_name == podling_name) + ppmc = (await db_session.execute(statement)).scalar_one_or_none() + if not ppmc: + ppmc = PMC(project_name=podling_name) + db_session.add(ppmc) + + # Update PPMC data from groups.json + ppmc.is_podling = True + ppmc.pmc_members = groups_data.get(f"{podling_name}-pmc", []) + ppmc.committers = groups_data.get(podling_name, []) + # Use PPMC members as release managers + ppmc.release_managers = ppmc.pmc_members updated_count += 1 @@ -149,10 +171,13 @@ async def secret_pmcs_update() -> str | Response: tooling_pmc.pmc_members = ["wave", "tn", "sbp"] tooling_pmc.committers = ["wave", "tn", "sbp"] tooling_pmc.release_managers = ["wave"] + tooling_pmc.is_podling = False - await flash(f"Successfully updated {updated_count} PMCs from Whimsy", "success") + await flash( + f"Successfully updated {updated_count} projects (PMCs and PPMCs) with membership data", "success" + ) except Exception as e: - await flash(f"Failed to update PMCs: {e!s}", "error") + await flash(f"Failed to update projects: {e!s}", "error") return redirect(url_for("secret_blueprint.secret_pmcs_update")) @@ -160,6 +185,35 @@ async def secret_pmcs_update() -> str | Response: return await render_template("secret/update-pmcs.html") +async def secret_pmcs_update_data() -> tuple[ApacheProjects, dict, dict]: + """Fetch and update PMCs and podlings from remote data.""" + # Fetch committee-info.json from whimsy.apache.org + try: + apache_projects = await get_apache_project_data() + except (httpx.RequestError, json.JSONDecodeError) as e: + raise FlashError(f"Failed to fetch committee data: {e!s}") + + # Fetch podlings data from projects.apache.org + try: + async with httpx.AsyncClient() as client: + response = await client.get(_PROJECT_PODLINGS_URL) + response.raise_for_status() + podlings_data = response.json() + except (httpx.RequestError, json.JSONDecodeError) as e: + raise FlashError(f"Failed to fetch podling data: {e!s}") + + # Fetch groups data from projects.apache.org + try: + async with httpx.AsyncClient() as client: + response = await client.get(_PROJECT_GROUPS_URL) + response.raise_for_status() + groups_data = response.json() + except (httpx.RequestError, json.JSONDecodeError) as e: + raise FlashError(f"Failed to fetch groups data: {e!s}") + + return apache_projects, podlings_data, groups_data + + @blueprint.route("/debug/database") async def secret_debug_database() -> str: """Debug information about the database.""" diff --git a/atr/db/models.py b/atr/db/models.py index 226b73f..ce37d13 100644 --- a/atr/db/models.py +++ b/atr/db/models.py @@ -80,6 +80,8 @@ class VotePolicy(SQLModel, table=True): class PMC(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) project_name: str = Field(unique=True) + # True if this is an incubator podling with a PPMC, otherwise False + is_podling: bool = Field(default=False) # One-to-many: A PMC can have multiple product lines, each product line belongs to one PMC product_lines: list["ProductLine"] = Relationship(back_populates="pmc") @@ -98,6 +100,13 @@ class PMC(SQLModel, table=True): # One-to-many: A PMC can have multiple releases releases: list["Release"] = Relationship(back_populates="pmc") + @property + def display_name(self) -> str: + """Get the display name for the PMC/PPMC.""" + if self.is_podling: + return f"{self.project_name} (podling)" + return self.project_name + class ProductLine(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) diff --git a/atr/routes.py b/atr/routes.py index f6d8ba6..1536891 100644 --- a/atr/routes.py +++ b/atr/routes.py @@ -87,6 +87,9 @@ algorithms = { } +class FlashError(RuntimeError): ... + + @asynccontextmanager async def ephemeral_gpg_home(): """Create a temporary directory for an isolated GPG home, and clean it up on exit.""" @@ -99,11 +102,13 @@ async def ephemeral_gpg_home(): async def release_attach_post(session: ClientSession, request: Request) -> Response: """Handle POST request for attaching package artifacts to a release.""" - res = await release_attach_post_validate(request) - # Not particularly elegant - if isinstance(res, Response): - return res - release_key, artifact_file, checksum_file, signature_file, artifact_type = res + try: + release_key, artifact_file, checksum_file, signature_file, artifact_type = await release_attach_post_validate( + request + ) + except FlashError as e: + await flash(f"{e!s}", "error") + return redirect(url_for("root_candidate_attach")) # This must come here to appease the type checker if artifact_file.filename is None: await flash("Release artifact filename is required", "error") @@ -114,11 +119,12 @@ async def release_attach_post(session: ClientSession, request: Request) -> Respo async with db_session.begin(): # Process and save the files try: - ok, artifact_sha3, artifact_size, artifact_sha512, signature_sha3 = await release_attach_post_session( - db_session, release_key, artifact_file, checksum_file, signature_file - ) - if not ok: - # The flash error is already set by the helper + try: + artifact_sha3, artifact_size, artifact_sha512, signature_sha3 = await release_attach_post_session( + db_session, release_key, artifact_file, checksum_file, signature_file + ) + except FlashError as e: + await flash(f"{e!s}", "error") return redirect(url_for("root_candidate_attach")) # Create the package record @@ -144,18 +150,14 @@ async def release_attach_post(session: ClientSession, request: Request) -> Respo async def release_attach_post_validate( request: Request, -) -> Response | tuple[str, FileStorage, FileStorage | None, FileStorage | None, str]: - async def flash_error_and_redirect(message: str) -> Response: - await flash(message, "error") - return redirect(url_for("root_candidate_attach")) - +) -> tuple[str, FileStorage, FileStorage | None, FileStorage | None, str]: form = await request.form # TODO: Check that the submitter is a committer of the project release_key = form.get("release_key") if (not release_key) or (not isinstance(release_key, str)): - return await flash_error_and_redirect("Release key is required") + raise FlashError("Release key is required") # Get all uploaded files files = await request.files @@ -163,31 +165,50 @@ async def release_attach_post_validate( checksum_file = files.get("release_checksum") signature_file = files.get("release_signature") if not isinstance(artifact_file, FileStorage): - return await flash_error_and_redirect("Release artifact file is required") + raise FlashError("Release artifact file is required") if checksum_file is not None and not isinstance(checksum_file, FileStorage): - return await flash_error_and_redirect("Problem with checksum file") + raise FlashError("Problem with checksum file") if signature_file is not None and not isinstance(signature_file, FileStorage): - return await flash_error_and_redirect("Problem with signature file") + raise FlashError("Problem with signature file") # Get and validate artifact type artifact_type = form.get("artifact_type") if (not artifact_type) or (not isinstance(artifact_type, str)): - return await flash_error_and_redirect("Artifact type is required") + raise FlashError("Artifact type is required") if artifact_type not in ["source", "binary", "reproducible"]: - return await flash_error_and_redirect("Invalid artifact type") + raise FlashError("Invalid artifact type") return release_key, artifact_file, checksum_file, signature_file, artifact_type +async def get_artifact_info( + db_session: AsyncSession, uploads_path: Path, artifact_file: FileStorage +) -> tuple[str, str, int]: + # In a separate function to appease the complexity checker + artifact_sha3, artifact_size = await save_file_by_hash(uploads_path, artifact_file) + + # Check for duplicates by artifact_sha3 before proceeding + statement = select(Package).where(Package.artifact_sha3 == artifact_sha3) + duplicate = (await db_session.execute(statement)).first() + if duplicate: + # Remove the saved file since we won't be using it + await aiofiles.os.remove(uploads_path / artifact_sha3) + raise FlashError("This exact file has already been uploaded to another release") + + # Compute SHA-512 of the artifact for the package record + return artifact_sha3, compute_sha512(uploads_path / artifact_sha3), artifact_size + + async def release_attach_post_session( db_session: AsyncSession, release_key: str, artifact_file: FileStorage, checksum_file: FileStorage | None, signature_file: FileStorage | None, -) -> tuple[bool, str, int, str, str | None]: +) -> tuple[str, int, str, str | None]: """Helper function for release_attach_post.""" - # First check for duplicates + + # First check for duplicates by filename statement = select(Package).where( Package.release_key == release_key, Package.filename == artifact_file.filename, @@ -195,18 +216,14 @@ async def release_attach_post_session( duplicate = (await db_session.execute(statement)).first() if duplicate: - await flash("This release artifact has already been uploaded", "error") - return False, "", 0, "", None + raise FlashError("This release artifact has already been uploaded") # Save files using their hashes as filenames uploads_path = Path(get_release_storage_dir()) try: - artifact_sha3, artifact_size = await save_file_by_hash(uploads_path, artifact_file) - # Compute SHA-512 of the artifact for the package record - artifact_sha512 = compute_sha512(uploads_path / artifact_sha3) + artifact_sha3, artifact_sha512, artifact_size = await get_artifact_info(db_session, uploads_path, artifact_file) except Exception as e: - await flash(f"Error saving artifact file: {e!s}", "error") - return False, "", 0, "", None + raise FlashError(f"Error saving artifact file: {e!s}") # Note: "error" is not permitted past this point # Because we don't want to roll back saving the artifact @@ -233,7 +250,7 @@ async def release_attach_post_session( except Exception as e: await flash(f"Warning: Could not save signature file: {e!s}", "warning") - return True, artifact_sha3, artifact_size, artifact_sha512, signature_sha3 + return artifact_sha3, artifact_size, artifact_sha512, signature_sha3 async def release_create_post(session: ClientSession, request: Request) -> Response: @@ -244,24 +261,15 @@ async def release_create_post(session: ClientSession, request: Request) -> Respo if not project_name: raise ASFQuartException("Project name is required", errorcode=400) - version = form.get("version") - if not version: - raise ASFQuartException("Version is required", errorcode=400) - product_name = form.get("product_name") if not product_name: raise ASFQuartException("Product name is required", errorcode=400) - # Verify user is a PMC member or committer of the project - if project_name not in session.committees and project_name not in session.projects: - raise ASFQuartException( - f"You must be a PMC member or committer of {project_name} to submit a release candidate", errorcode=403 - ) - - # Generate a 128-bit random token for the release storage key - # TODO: Perhaps we should call this the release_id instead - storage_token = secrets.token_hex(16) + version = form.get("version") + if not version: + raise ASFQuartException("Version is required", errorcode=400) + # TODO: Forbid creating a release with an existing project, product, and version # Create the release record in the database async with get_session() as db_session: async with db_session.begin(): @@ -273,9 +281,21 @@ async def release_create_post(session: ClientSession, request: Request) -> Respo APP.logger.debug(f"Available projects: {session.projects}") raise ASFQuartException("PMC not found", errorcode=404) + # Verify user is a PMC member or committer of the project + # We use pmc.display_name, so this must come within the transaction + if project_name not in session.committees and project_name not in session.projects: + raise ASFQuartException( + f"You must be a PMC member or committer of {pmc.display_name} to submit a release candidate", + errorcode=403, + ) + + # Generate a 128-bit random token for the release storage key + # TODO: Perhaps we should call this the release_key instead + storage_key = secrets.token_hex(16) + # Create release record release = Release( - storage_key=storage_token, + storage_key=storage_key, stage=ReleaseStage.CANDIDATE, phase=ReleasePhase.RELEASE_CANDIDATE, pmc_id=pmc.id, @@ -284,13 +304,8 @@ async def release_create_post(session: ClientSession, request: Request) -> Respo ) db_session.add(release) - # TODO: Create or link to product line - # For now, we'll just create releases without product lines - # What sort of role do product lines play in our UX? - # Redirect to the attach artifacts page with the storage token - # We should possibly have a results, or list of releases, page instead - return redirect(url_for("root_candidate_attach", storage_key=storage_token)) + return redirect(url_for("root_candidate_attach", storage_key=storage_key)) @APP.route("/") @@ -316,16 +331,18 @@ async def root_candidate_attach() -> Response | str: # Get all releases where the user is a PMC member or committer of the associated PMC async with get_session() as db_session: + # TODO: This duplicates code in root_candidate_review release_pmc = selectinload(cast(InstrumentedAttribute[PMC], Release.pmc)) statement = select(Release).options(release_pmc).join(PMC).where(Release.stage == ReleaseStage.CANDIDATE) releases = (await db_session.execute(statement)).scalars().all() - # Filter to only show releases for PMCs where the user is a member or committer + # Filter to only show releases for PMCs or PPMCs where the user is a member or committer # Can we do this in sqlmodel using JSON container operators? user_releases = [] for r in releases: if r.pmc is None: continue + # For PPMCs the "members" are stored in the committers field if session.uid in r.pmc.pmc_members or session.uid in r.pmc.committers: user_releases.append(r) @@ -350,12 +367,20 @@ async def root_candidate_create() -> Response | str: if request.method == "POST": return await release_create_post(session, request) + # Get PMC objects for all projects the user is a member of + async with get_session() as db_session: + from sqlalchemy.sql.expression import ColumnElement + + project_list = session.committees + session.projects + project_name: ColumnElement[str] = cast(ColumnElement[str], PMC.project_name) + statement = select(PMC).where(project_name.in_(project_list)) + user_pmcs = (await db_session.execute(statement)).scalars().all() + # For GET requests, show the form return await render_template( "candidate-create.html", asf_id=session.uid, - pmc_memberships=session.committees, - committer_projects=session.projects, + user_pmcs=user_pmcs, ) @@ -620,9 +645,10 @@ async def root_candidate_review() -> str: raise ASFQuartException("Not authenticated", errorcode=401) async with get_session() as db_session: - # Get all releases where the user is a PMC member of the associated PMC + # Get all releases where the user is a PMC member or committer # TODO: We don't actually record who uploaded the release candidate # We should probably add that information! + # TODO: This duplicates code in root_candidate_attach release_pmc = selectinload(cast(InstrumentedAttribute[PMC], Release.pmc)) release_packages = selectinload(cast(InstrumentedAttribute[list[Package]], Release.packages)) statement = ( @@ -633,12 +659,13 @@ async def root_candidate_review() -> str: ) releases = (await db_session.execute(statement)).scalars().all() - # Filter to only show releases for PMCs where the user is a member + # Filter to only show releases for PMCs or PPMCs where the user is a member or committer user_releases = [] for r in releases: if r.pmc is None: continue - if session.uid in r.pmc.pmc_members: + # For PPMCs the "members" are stored in the committers field + if session.uid in r.pmc.pmc_members or session.uid in r.pmc.committers: user_releases.append(r) return await render_template("candidate-review.html", releases=user_releases) diff --git a/atr/templates/candidate-attach.html b/atr/templates/candidate-attach.html index 010d0c6..9280205 100644 --- a/atr/templates/candidate-attach.html +++ b/atr/templates/candidate-attach.html @@ -133,7 +133,7 @@ {% for release in releases %} <option value="{{ release.storage_key }}" {% if release.storage_key == selected_release %}selected{% endif %}> - {{ release.pmc.project_name }} - {{ release.version }} + {{ release.pmc.display_name }} - {{ release.version }} </option> {% endfor %} </select> @@ -168,7 +168,12 @@ <td> <div class="radio-group"> <div> - <input type="radio" id="source" name="artifact_type" value="source" required /> + <input type="radio" + id="source" + name="artifact_type" + value="source" + required + checked /> <label for="source">Source archive</label> </div> <div> diff --git a/atr/templates/candidate-create.html b/atr/templates/candidate-create.html index b0d476c..5922607 100644 --- a/atr/templates/candidate-create.html +++ b/atr/templates/candidate-create.html @@ -83,11 +83,11 @@ <td> <select id="project_name" name="project_name" required> <option value="">Select a project...</option> - {% for project in (pmc_memberships + committer_projects)|unique|sort %} - <option value="{{ project }}">{{ project }}</option> + {% for pmc in user_pmcs|sort(attribute='project_name') %} + <option value="{{ pmc.project_name }}">{{ pmc.display_name }}</option> {% endfor %} </select> - {% if not pmc_memberships and not committer_projects %} + {% if not user_pmcs %} <p class="error-message">You must be a PMC member or committer to submit a release candidate.</p> {% endif %} </td> @@ -115,8 +115,7 @@ <tr> <td></td> <td> - <button type="submit" - {% if not pmc_memberships and not committer_projects %}disabled{% endif %}>Create release</button> + <button type="submit" {% if not user_pmcs %}disabled{% endif %}>Create release</button> </td> </tr> </tbody> diff --git a/atr/templates/candidate-review.html b/atr/templates/candidate-review.html index d118e6b..94dfe5f 100644 --- a/atr/templates/candidate-review.html +++ b/atr/templates/candidate-review.html @@ -136,7 +136,7 @@ {% if releases %} {% for release in releases %} <div class="candidate-header"> - <h3>{{ release.pmc.project_name }}</h3> + <h3>{{ release.pmc.display_name }}</h3> <div class="candidate-meta"> <span class="candidate-meta-item">Version: {{ release.version }}</span> <span class="candidate-meta-item">Stage: {{ release.stage.value }}</span> diff --git a/atr/templates/candidate-signature-verify.html b/atr/templates/candidate-signature-verify.html index f9be33f..a3e0295 100644 --- a/atr/templates/candidate-signature-verify.html +++ b/atr/templates/candidate-signature-verify.html @@ -154,7 +154,7 @@ <h1>Verify release signatures</h1> <div class="candidate-header"> - <h3>{{ release.pmc.project_name }}</h3> + <h3>{{ release.pmc.display_name }}</h3> <div class="candidate-meta"> <span class="candidate-meta-item">Version: {{ release.version }}</span> <span class="candidate-meta-item">Stage: {{ release.stage.value }}</span> diff --git a/atr/templates/pmc-directory.html b/atr/templates/pmc-directory.html index 65f7f14..881c6e4 100644 --- a/atr/templates/pmc-directory.html +++ b/atr/templates/pmc-directory.html @@ -1,11 +1,11 @@ {% extends "layouts/base.html" %} {% block title %} - Project Management Committees ~ ATR + Project directory ~ ATR {% endblock title %} {% block description %} - List of all PMCs and their latest releases. + List of all ASF projects and their latest releases. {% endblock description %} {% block head_extra %} @@ -68,8 +68,8 @@ {% endblock head_extra %} {% block content %} - <h1>Project Management Committees</h1> - <p class="intro">Current Apache PMCs and their releases:</p> + <h1>Project directory</h1> + <p class="intro">Current ASF projects and their releases:</p> <input type="text" id="pmc-filter" @@ -79,7 +79,7 @@ <div class="pmc-grid"> {% for pmc in pmcs %} <div class="pmc-card"> - <div class="pmc-name">{{ pmc.project_name }}</div> + <div class="pmc-name">{{ pmc.display_name }}</div> <div class="pmc-stats"> <div class="stat-item"> <span class="stat-label">PMC Members</span> diff --git a/atr/templates/secret/update-pmcs.html b/atr/templates/secret/update-pmcs.html index 274147b..40bf154 100644 --- a/atr/templates/secret/update-pmcs.html +++ b/atr/templates/secret/update-pmcs.html @@ -1,11 +1,11 @@ {% extends "layouts/base.html" %} {% block title %} - Update PMCs ~ ATR + Update projects ~ ATR {% endblock title %} {% block description %} - Update PMCs from remote, authoritative committee-info.json. + Update PMCs and podlings from remote data sources. {% endblock description %} {% block stylesheets %} @@ -68,8 +68,10 @@ {% endblock stylesheets %} {% block content %} - <h1>Update PMCs</h1> - <p class="intro">This page allows you to update the PMC information in the database from committee-info.json.</p> + <h1>Update Projects</h1> + <p class="intro"> + This page allows you to update PMC and podling information in the database from remote data sources. + </p> {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} @@ -79,11 +81,16 @@ <div class="warning"> <p> - <strong>Note:</strong> This operation will update all PMC information, including member lists and release manager assignments. + <strong>Note:</strong> This operation will update all project information, including: </p> + <ul> + <li>PMC member lists and release manager assignments</li> + <li>Podling status and basic information</li> + <li>Project metadata and relationships</li> + </ul> </div> <form method="post"> - <button type="submit">Update PMCs</button> + <button type="submit">Update Projects</button> </form> {% endblock content %} diff --git a/migrations/versions/6776f795ec62_initial_schema.py b/migrations/versions/d3ff8c13bb8b_initial_schema.py similarity index 84% rename from migrations/versions/6776f795ec62_initial_schema.py rename to migrations/versions/d3ff8c13bb8b_initial_schema.py index 3f1e9ef..a19128c 100644 --- a/migrations/versions/6776f795ec62_initial_schema.py +++ b/migrations/versions/d3ff8c13bb8b_initial_schema.py @@ -1,15 +1,15 @@ """initial_schema -Revision ID: 6776f795ec62 +Revision ID: d3ff8c13bb8b Revises: -Create Date: 2025-02-20 16:02:41.618093 +Create Date: 2025-02-20 16:49:57.374714 """ from collections.abc import Sequence # revision identifiers, used by Alembic. -revision: str = "6776f795ec62" +revision: str = "d3ff8c13bb8b" down_revision: str | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tooling.apache.org For additional commands, e-mail: dev-h...@tooling.apache.org