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-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new e931cdf Convert the vote task to the improved task style
e931cdf is described below
commit e931cdfea80a6c92e3713a1ea26f31ca00bd9dc5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Mar 31 19:44:11 2025 +0100
Convert the vote task to the improved task style
---
atr/mail.py | 94 +++++-----------
atr/routes/candidate.py | 20 ++--
atr/tasks/mailtest.py | 185 -------------------------------
atr/tasks/vote.py | 282 +++++++++++++++++++-----------------------------
atr/worker.py | 5 +-
poetry.lock | 18 +++-
pyproject.toml | 1 +
scripts/poetry/add | 3 +-
8 files changed, 171 insertions(+), 437 deletions(-)
diff --git a/atr/mail.py b/atr/mail.py
index 2f78bb0..f2227eb 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -15,16 +15,16 @@
# specific language governing permissions and limitations
# under the License.
+import asyncio
import datetime
import email.utils as utils
import io
import logging
import smtplib
-import ssl
import time
import uuid
-from typing import Any
+import aiosmtplib
import dkim
import dns.rdtypes.ANY.MX as MX
import dns.resolver as resolver
@@ -43,25 +43,6 @@ global_email_contact: str = f"contact@{global_domain}"
global_secret_key: str | None = None
-class ArtifactEvent:
- """Simple data class to represent an artifact send event."""
-
- def __init__(self, email_recipient: str, artifact_name: str, token: str)
-> None:
- self.artifact_name = artifact_name
- self.email_recipient = email_recipient
- self.token = token
-
-
-class LoggingSMTP(smtplib.SMTP):
- def _print_debug(self, *args: Any) -> None:
- template = ["%s"] * len(args)
- if self.debuglevel > 1:
- template.append("%s")
- _LOGGER.info(" ".join(template), datetime.datetime.now().time(),
*args)
- else:
- _LOGGER.info(" ".join(template), *args)
-
-
class VoteEvent:
"""Data class to represent a release vote event."""
@@ -75,7 +56,7 @@ class VoteEvent:
self.vote_end = vote_end
-def send(event: ArtifactEvent | VoteEvent) -> None:
+async def send(event: VoteEvent) -> None:
"""Send an email notification about an artifact or a vote."""
_LOGGER.info(f"Sending email for event: {event}")
from_addr = global_email_contact
@@ -87,8 +68,7 @@ def send(event: ArtifactEvent | VoteEvent) -> None:
mid = f"<{uuid.uuid4()}@{global_domain}>"
# Different message format depending on event type
- if isinstance(event, VoteEvent):
- msg_text = f"""
+ msg_text = f"""
From: {from_addr}
To: {to_addr}
Subject: {event.subject}
@@ -96,27 +76,6 @@ Date: {utils.formatdate(localtime=True)}
Message-ID: {mid}
{event.body}
-"""
- else:
- # ArtifactEvent
- # This was just for testing
- msg_text = f"""
-From: {from_addr}
-To: {to_addr}
-Subject: {event.artifact_name}
-Date: {utils.formatdate(localtime=True)}
-Message-ID: {mid}
-
-The {event.artifact_name} artifact has been uploaded.
-
-The artifact is available for download at:
-
-https://{global_domain}/artifact/{event.token}
-
-If you have any questions, please reply to this email.
-
---\x20
-[NAME GOES HERE]
"""
# Convert Unix line endings to CRLF
@@ -126,7 +85,7 @@ If you have any questions, please reply to this email.
_LOGGER.info(f"sending message: {msg_text}")
try:
- _send_many(from_addr, [to_addr], msg_text)
+ await _send_many(from_addr, [to_addr], msg_text)
except Exception as e:
_LOGGER.error(f"send error: {e}")
raise e
@@ -143,10 +102,10 @@ def set_secret_key(key: str) -> None:
global_secret_key = key
-def _resolve_mx_records(domain: str) -> list[tuple[str, int]]:
+async def _resolve_mx_records(domain: str) -> list[tuple[str, int]]:
+ """Resolve MX records."""
try:
- # Query MX records
- mx_records = resolver.resolve(domain, "MX")
+ mx_records = await asyncio.to_thread(resolver.resolve, domain, "MX")
mxs = []
for rdata in mx_records:
@@ -154,7 +113,7 @@ def _resolve_mx_records(domain: str) -> list[tuple[str,
int]]:
raise ValueError(f"Unexpected MX record type: {type(rdata)}")
mx = rdata
mxs.append((mx.exchange.to_text(True), mx.preference))
- # Sort by preference, array position one
+ # Sort by preference
mxs.sort(key=lambda x: x[1])
if not mxs:
@@ -164,7 +123,7 @@ def _resolve_mx_records(domain: str) -> list[tuple[str,
int]]:
return mxs
-def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) -> None:
+async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) ->
None:
"""Send an email to multiple recipients with DKIM signing."""
message_bytes = bytes(msg_text, "utf-8")
@@ -195,13 +154,13 @@ def _send_many(from_addr: str, to_addrs: list[str],
msg_text: str) -> None:
if domain == "localhost":
mxs = [("127.0.0.1", 0)]
else:
- mxs = _resolve_mx_records(domain)
+ mxs = await _resolve_mx_records(domain)
# Try each MX server
errors = []
for mx_host, _ in mxs:
try:
- _send_one(mx_host, from_addr, addr, dkim_reader)
+ await _send_one(mx_host, from_addr, addr, dkim_reader)
# Success, no need to try other MX servers
break
except Exception as e:
@@ -213,7 +172,7 @@ def _send_many(from_addr: str, to_addrs: list[str],
msg_text: str) -> None:
raise Exception("; ".join(errors))
-def _send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader:
io.StringIO) -> None:
+async def _send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader:
io.StringIO) -> None:
"""Send an email to a single recipient via the ASF mail relay."""
default_timeout_seconds = 30
_validate_recipient(to_addr)
@@ -222,28 +181,29 @@ def _send_one(mx_host: str, from_addr: str, to_addr: str,
msg_reader: io.StringI
# Connect to the ASF mail relay
# TODO: Use asfpy for sending mail
mail_relay = "mail-relay.apache.org"
- _LOGGER.info(f"Connecting to {mail_relay}:587")
- smtp = LoggingSMTP(mail_relay, 587, timeout=default_timeout_seconds)
- smtp.set_debuglevel(2)
+ _LOGGER.info(f"Connecting async to {mail_relay}:587")
+ smtp = aiosmtplib.SMTP(hostname=mail_relay, port=587,
timeout=default_timeout_seconds)
+ await smtp.connect()
+ _LOGGER.info(f"Connected to {smtp.hostname}:{smtp.port}")
# Identify ourselves to the server
- smtp.ehlo(global_domain)
+ await smtp.ehlo()
- # Use STARTTLS for port 587
- context = ssl.create_default_context()
- context.minimum_version = ssl.TLSVersion.TLSv1_2
- smtp.starttls(context=context)
- smtp.ehlo(global_domain)
+ # # Use STARTTLS for port 587
+ # context = ssl.create_default_context()
+ # context.minimum_version = ssl.TLSVersion.TLSv1_2
+ # await smtp.starttls(tls_context=context)
+ await smtp.ehlo()
# Send the message
- smtp.mail(from_addr)
- smtp.rcpt(to_addr)
- smtp.data(msg_reader.read())
+ await smtp.sendmail(from_addr, [to_addr], msg_reader.read())
# Close the connection
- smtp.quit()
+ await smtp.quit()
except (OSError, smtplib.SMTPException) as e:
+ # TODO: Check whether aiosmtplib raises different exceptions
+ _LOGGER.error(f"Async SMTP error: {e}")
raise Exception(f"SMTP error: {e}")
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index fff288d..9eb4623 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -28,6 +28,8 @@ import wtforms
import atr.db as db
import atr.db.models as models
import atr.routes as routes
+import atr.tasks.checks as checks
+import atr.tasks.vote as tasks_vote
import atr.user as user
import atr.util as util
@@ -156,15 +158,15 @@ async def vote_project(session: routes.CommitterSession,
project_name: str, vers
# Create a task for vote initiation
task = models.Task(
status=models.TaskStatus.QUEUED,
- task_type="vote_initiate",
- task_args=[
- release_name,
- email_to,
- vote_duration,
- gpg_key_id,
- commit_hash,
- session.uid,
- ],
+ task_type=checks.function_key(tasks_vote.initiate),
+ task_args=tasks_vote.Initiate(
+ release_name=release_name,
+ email_to=email_to,
+ vote_duration=vote_duration,
+ gpg_key_id=gpg_key_id,
+ commit_hash=commit_hash,
+ initiator_id=session.uid,
+ ).model_dump(),
)
data.add(task)
diff --git a/atr/tasks/mailtest.py b/atr/tasks/mailtest.py
deleted file mode 100644
index 7394fee..0000000
--- a/atr/tasks/mailtest.py
+++ /dev/null
@@ -1,185 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-import dataclasses
-import logging
-import os
-from typing import Any, Final
-
-import atr.db.models as models
-import atr.tasks.task as task
-
-# Configure detailed logging
-_LOGGER: Final = logging.getLogger(__name__)
-_LOGGER.setLevel(logging.DEBUG)
-
-# Create file handler for test.log
-_HANDLER: Final = logging.FileHandler("tasks-mailtest.log")
-_HANDLER.setLevel(logging.DEBUG)
-
-# Create formatter with detailed information
-_HANDLER.setFormatter(
- logging.Formatter(
- "[%(asctime)s.%(msecs)03d] [%(process)d] [%(levelname)s]
[%(name)s:%(funcName)s:%(lineno)d] %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
- )
-)
-_LOGGER.addHandler(_HANDLER)
-# Ensure parent loggers don't duplicate messages
-_LOGGER.propagate = False
-
-_LOGGER.info("Mail test module imported")
-
-
-# TODO: Use a Pydantic model instead
[email protected]
-class Args:
- artifact_name: str
- email_recipient: str
- token: str
-
- @staticmethod
- def from_list(args: list[str]) -> "Args":
- """Parse command line arguments."""
- _LOGGER.debug(f"Parsing arguments: {args}")
-
- if len(args) != 3:
- _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected
3")
- raise ValueError("Invalid number of arguments")
-
- artifact_name = args[0]
- email_recipient = args[1]
- token = args[2]
-
- if not isinstance(artifact_name, str):
- _LOGGER.error(f"Artifact name must be a string, got
{type(artifact_name)}")
- raise ValueError("Artifact name must be a string")
- if not isinstance(email_recipient, str):
- _LOGGER.error(f"Email recipient must be a string, got
{type(email_recipient)}")
- raise ValueError("Email recipient must be a string")
- if not isinstance(token, str):
- _LOGGER.error(f"Token must be a string, got {type(token)}")
- raise ValueError("Token must be a string")
- _LOGGER.debug("All argument validations passed")
-
- args_obj = Args(
- artifact_name=artifact_name,
- email_recipient=email_recipient,
- token=token,
- )
-
- _LOGGER.info(f"Args object created: {args_obj}")
- return args_obj
-
-
-def send(args: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any,
...]]:
- """Send a test email."""
- _LOGGER.info(f"Sending with args: {args}")
- try:
- _LOGGER.debug("Delegating to send_core function")
- status, error, result = send_core(args)
- _LOGGER.info(f"Send completed with status: {status}")
- return status, error, result
- except Exception as e:
- _LOGGER.exception(f"Error in send function: {e}")
- return task.FAILED, str(e), tuple()
-
-
-def send_core(args_list: list[str]) -> tuple[models.TaskStatus, str | None,
tuple[Any, ...]]:
- """Send a test email."""
- import asyncio
-
- import atr.db.service as service
- import atr.mail as mail
-
- _LOGGER.info("Starting send_core")
- try:
- # Configure root _LOGGER to also write to our log file
- # This ensures logs from mail.py, using the root _LOGGER, are captured
- root_logger = logging.getLogger()
- # Check whether our file handler is already added, to avoid duplicates
- has_our_handler = any(
- (isinstance(h, logging.FileHandler) and
h.baseFilename.endswith("tasks-mailtest.log"))
- for h in root_logger.handlers
- )
- if not has_our_handler:
- # Add our file handler to the root _LOGGER
- root_logger.addHandler(_HANDLER)
- _LOGGER.info("Added file handler to root _LOGGER to capture
mail.py logs")
-
- _LOGGER.debug(f"Parsing arguments: {args_list}")
- args = Args.from_list(args_list)
- _LOGGER.info(
- f"Args parsed successfully: artifact_name={args.artifact_name},
email_recipient={args.email_recipient}"
- )
-
- # Check if the recipient is allowed
- # They must be a PMC member of tooling or [email protected]
- email_recipient = args.email_recipient
- local_part, domain = email_recipient.split("@", 1)
-
- # Allow [email protected]
- if email_recipient != "[email protected]":
- # Must be a PMC member of tooling
- # Since get_pmc_by_name is async, we need to run it in an event
loop
- # TODO: We could make a sync version
- tooling_committee =
asyncio.run(service.get_committee_by_name("tooling"))
-
- if not tooling_committee:
- error_msg = "Tooling committee not found in database"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- if domain != "apache.org":
- error_msg = f"Email domain must be apache.org, got {domain}"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- if local_part not in tooling_committee.committee_members:
- error_msg = f"Email recipient {local_part} is not a member of
the tooling committee"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- _LOGGER.info(f"Recipient {email_recipient} is a tooling committee
member, allowed")
-
- # Load and set DKIM key
- try:
- project_root =
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
- dkim_path = os.path.join(project_root, "state", "dkim.private")
-
- with open(dkim_path) as f:
- dkim_key = f.read()
- mail.set_secret_key(dkim_key.strip())
- _LOGGER.info("DKIM key loaded and set successfully")
- except Exception as e:
- error_msg = f"Failed to load DKIM key: {e}"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- event = mail.ArtifactEvent(
- artifact_name=args.artifact_name,
- email_recipient=args.email_recipient,
- token=args.token,
- )
- mail.send(event)
- _LOGGER.info(f"Email sent successfully to {args.email_recipient}")
-
- return task.COMPLETED, None, tuple()
-
- except Exception as e:
- _LOGGER.exception(f"Error in send_core: {e}")
- return task.FAILED, str(e), tuple()
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index cc8578a..d26004c 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -15,14 +15,17 @@
# specific language governing permissions and limitations
# under the License.
-import dataclasses
import datetime
import logging
import os
from typing import Any, Final
-import atr.db.models as models
-import atr.tasks.task as task
+import aiofiles
+import pydantic
+
+import atr.db as db
+import atr.mail as mail
+import atr.tasks.checks as checks
# Configure detailed logging
_LOGGER: Final = logging.getLogger(__name__)
@@ -46,147 +49,96 @@ _LOGGER.propagate = False
_LOGGER.info("Vote module imported")
[email protected]
-class Args:
- """Arguments for the vote_initiate task."""
-
- release_name: str
- email_to: str
- vote_duration: str
- gpg_key_id: str
- commit_hash: str
- initiator_id: str
-
- @staticmethod
- def from_list(args: list[str]) -> "Args":
- """Parse task arguments."""
- _LOGGER.debug(f"Parsing arguments: {args}")
-
- if len(args) != 6:
- _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected
6")
- raise ValueError("Invalid number of arguments")
-
- release_name = args[0]
- email_to = args[1]
- vote_duration = args[2]
- gpg_key_id = args[3]
- commit_hash = args[4]
- initiator_id = args[5]
-
- # Type checking
- for arg_name, arg_value in [
- ("release_name", release_name),
- ("email_to", email_to),
- ("vote_duration", vote_duration),
- ("gpg_key_id", gpg_key_id),
- ("commit_hash", commit_hash),
- ("initiator_id", initiator_id),
- ]:
- if not isinstance(arg_value, str):
- _LOGGER.error(f"{arg_name} must be a string, got
{type(arg_value)}")
- raise ValueError(f"{arg_name} must be a string")
-
- _LOGGER.debug("All argument validations passed")
-
- args_obj = Args(
- release_name=release_name,
- email_to=email_to,
- vote_duration=vote_duration,
- gpg_key_id=gpg_key_id,
- commit_hash=commit_hash,
- initiator_id=initiator_id,
- )
+class VoteInitiationError(Exception): ...
+
+
+class Initiate(pydantic.BaseModel):
+ """Arguments for the task to start a vote."""
- _LOGGER.info(f"Args object created: {args_obj}")
- return args_obj
+ release_name: str = pydantic.Field(..., description="The name of the
release to vote on")
+ email_to: str = pydantic.Field(..., description="The mailing list address
to send the vote email to")
+ vote_duration: str = pydantic.Field(..., description="Duration of the vote
in hours, as a string")
+ gpg_key_id: str = pydantic.Field(..., description="GPG Key ID of the
initiator")
+ commit_hash: str = pydantic.Field(..., description="Commit hash the
artifacts were built from")
+ initiator_id: str = pydantic.Field(..., description="ASF ID of the vote
initiator")
-def initiate(args: list[str]) -> tuple[models.TaskStatus, str | None,
tuple[Any, ...]]:
[email protected]_model(Initiate)
+async def initiate(args: Initiate) -> str | None:
"""Initiate a vote for a release."""
- _LOGGER.info(f"Initiating vote with args: {args}")
try:
- _LOGGER.debug("Delegating to initiate_core function")
- status, error, result = initiate_core(args)
- _LOGGER.info(f"Vote initiation completed with status: {status}")
- return status, error, result
+ result_data = await _initiate_core_logic(args)
+ success_message = result_data.get("message", "Vote initiated
successfully, but message missing")
+ if not isinstance(success_message, str):
+ raise VoteInitiationError("Success message is not a string")
+ return success_message
+
+ except VoteInitiationError as e:
+ _LOGGER.error(f"Vote initiation failed: {e}")
+ raise
except Exception as e:
- _LOGGER.exception(f"Error in initiate function: {e}")
- return task.FAILED, str(e), tuple()
+ _LOGGER.exception(f"Unexpected error during vote initiation: {e}")
+ raise
-def initiate_core(args_list: list[str]) -> tuple[models.TaskStatus, str |
None, tuple[Any, ...]]:
+async def _initiate_core_logic(args: Initiate) -> dict[str, Any]:
"""Get arguments, create an email, and then send it to the recipient."""
- import atr.db.service as service
- import atr.mail
-
test_recipients = ["sbp"]
_LOGGER.info("Starting initiate_core")
- try:
- # Configure root _LOGGER to also write to our log file
- # This ensures logs from mail.py, using the root _LOGGER, are captured
- root_logger = logging.getLogger()
- # Check whether our file handler is already added, to avoid duplicates
- has_our_handler = any(
- (isinstance(h, logging.FileHandler) and
h.baseFilename.endswith("tasks-vote.log"))
- for h in root_logger.handlers
+
+ root_logger = logging.getLogger()
+ has_our_handler = any(
+ (isinstance(h, logging.FileHandler) and
h.baseFilename.endswith("tasks-vote.log")) for h in root_logger.handlers
+ )
+ if not has_our_handler:
+ root_logger.addHandler(_HANDLER)
+
+ async with db.session() as data:
+ release = await data.release(name=args.release_name, _project=True,
_committee=True).demand(
+ VoteInitiationError(f"Release {args.release_name} not found")
)
- if not has_our_handler:
- # Add our file handler to the root _LOGGER
- root_logger.addHandler(_HANDLER)
- _LOGGER.info("Added file handler to root _LOGGER to capture
mail.py logs")
-
- _LOGGER.debug(f"Parsing arguments: {args_list}")
- args = Args.from_list(args_list)
- _LOGGER.info(f"Args parsed successfully: {args}")
-
- # Get the release information
- release = service.get_release_by_name_sync(args.release_name)
- if not release:
- error_msg = f"Release with key {args.release_name} not found"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- # GPG key ID, just for testing the UI
- gpg_key_id = args.gpg_key_id
-
- # Calculate vote end date
- vote_duration_hours = int(args.vote_duration)
- vote_start = datetime.datetime.now(datetime.UTC)
- vote_end = vote_start + datetime.timedelta(hours=vote_duration_hours)
-
- # Format dates for email
- vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
-
- # Load and set DKIM key
- try:
- project_root =
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
- dkim_path = os.path.join(project_root, "state", "dkim.private")
-
- with open(dkim_path) as f:
- dkim_key = f.read()
- atr.mail.set_secret_key(dkim_key.strip())
- _LOGGER.info("DKIM key loaded and set successfully")
- except Exception as e:
- error_msg = f"Failed to load DKIM key: {e}"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- # Get PMC and project details
- if release.committee is None:
- error_msg = "Release has no associated committee"
- _LOGGER.error(error_msg)
- return task.FAILED, error_msg, tuple()
-
- committee_name = release.committee.name
- committee_display = release.committee.display_name
- project_name = release.project.name if release.project else "Unknown"
- version = release.version
-
- # Create email subject
- subject = f"[VOTE] Release Apache {committee_display} {project_name}
{version}"
-
- # Create email body with initiator ID
- body = f"""Hello {committee_name},
+
+ # GPG key ID, just for testing the UI
+ gpg_key_id = args.gpg_key_id
+
+ # Calculate vote end date
+ vote_duration_hours = int(args.vote_duration)
+ vote_start = datetime.datetime.now(datetime.UTC)
+ vote_end = vote_start + datetime.timedelta(hours=vote_duration_hours)
+
+ # Format dates for email
+ vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
+
+ # Load and set DKIM key
+ try:
+ project_root =
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ dkim_path = os.path.join(project_root, "state", "dkim.private")
+
+ async with aiofiles.open(dkim_path) as f:
+ dkim_key = await f.read()
+ mail.set_secret_key(dkim_key.strip())
+ _LOGGER.info("DKIM key loaded and set successfully")
+ except Exception as e:
+ error_msg = f"Failed to load DKIM key: {e}"
+ _LOGGER.error(error_msg)
+ raise VoteInitiationError(error_msg)
+
+ # Get PMC and project details
+ if release.committee is None:
+ error_msg = "Release has no associated committee"
+ _LOGGER.error(error_msg)
+ raise VoteInitiationError(error_msg)
+
+ committee_name = release.committee.name
+ committee_display = release.committee.display_name
+ project_name = release.project.name if release.project else "Unknown"
+ version = release.version
+
+ # Create email subject
+ subject = f"[VOTE] Release Apache {committee_display} {project_name}
{version}"
+
+ # Create email body with initiator ID
+ body = f"""Hello {committee_name},
I'd like to call a vote on releasing the following artifacts as
Apache {committee_display} {project_name} {version}.
@@ -213,44 +165,32 @@ Thanks,
{args.initiator_id}
"""
- # Store the original recipient for logging
- original_recipient = args.email_to
- # Only one test recipient is required for now
- test_recipient = test_recipients[0] + "@apache.org"
- _LOGGER.info(f"TEMPORARY: Overriding recipient from
{original_recipient} to {test_recipient}")
-
- # Create mail event with test recipient
- # Use test account instead of actual PMC list
- event = atr.mail.VoteEvent(
- release_name=args.release_name,
- email_recipient=test_recipient,
- subject=subject,
- body=body,
- vote_end=vote_end,
- )
-
- # Send the email
- atr.mail.send(event)
- _LOGGER.info(
- f"Vote email sent successfully to test account {test_recipient}
(would have been {original_recipient})"
- )
+ # Store the original recipient for logging
+ original_recipient = args.email_to
+ # Only one test recipient is required for now
+ test_recipient = test_recipients[0] + "@apache.org"
+ _LOGGER.info(f"TEMPORARY: Overriding recipient from {original_recipient}
to {test_recipient}")
+
+ # Create mail event with test recipient
+ # Use test account instead of actual PMC list
+ event = mail.VoteEvent(
+ release_name=args.release_name,
+ email_recipient=test_recipient,
+ subject=subject,
+ body=body,
+ vote_end=vote_end,
+ )
- # TODO: Update release status to indicate a vote is in progress
- # This would involve updating the database with the vote details
somehow
- return (
- task.COMPLETED,
- None,
- (
- {
- "message": "Vote initiated successfully (sent to test
account)",
- "original_email_to": original_recipient,
- "actual_email_to": test_recipient,
- "vote_end": vote_end_str,
- "subject": subject,
- },
- ),
- )
+ # Send the email
+ await mail.send(event)
+ _LOGGER.info(
+ f"Vote email sent successfully to test account {test_recipient} (would
have been {original_recipient})"
+ )
- except Exception as e:
- _LOGGER.exception(f"Error in initiate_core: {e}")
- return task.FAILED, str(e), tuple()
+ return {
+ "message": "Vote initiated successfully (sent to test account)",
+ "original_email_to": original_recipient,
+ "actual_email_to": test_recipient,
+ "vote_end": vote_end_str,
+ "subject": subject,
+ }
diff --git a/atr/worker.py b/atr/worker.py
index 13af8bf..04f6cbd 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -44,7 +44,6 @@ import atr.tasks.checks.hashing as hashing
import atr.tasks.checks.license as license
import atr.tasks.checks.rat as rat
import atr.tasks.checks.signature as signature
-import atr.tasks.mailtest as mailtest
import atr.tasks.rsync as rsync
import atr.tasks.sbom as sbom
import atr.tasks.task as task
@@ -195,6 +194,7 @@ async def _task_process(task_id: int, task_type: str,
task_args: list[str] | dic
checks.function_key(rat.check): rat.check,
checks.function_key(signature.check): signature.check,
checks.function_key(rsync.analyse): rsync.analyse,
+ checks.function_key(vote.initiate): vote.initiate,
}
# TODO: We should use a decorator to register these automatically
dict_task_handlers = {
@@ -204,8 +204,6 @@ async def _task_process(task_id: int, task_type: str,
task_args: list[str] | dic
# We plan to convert these to async dict handlers
list_task_handlers = {
"generate_cyclonedx_sbom": sbom.generate_cyclonedx,
- "mailtest_send": mailtest.send,
- "vote_initiate": vote.initiate,
}
task_results: tuple[Any, ...]
@@ -224,6 +222,7 @@ async def _task_process(task_id: int, task_type: str,
task_args: list[str] | dic
task_results = tuple()
status = task.FAILED
error = str(e)
+ _LOGGER.exception(f"Task {task_id} ({task_type}) failed: {e}")
elif isinstance(task_args, dict):
dict_handler = dict_task_handlers.get(task_type)
if not dict_handler:
diff --git a/poetry.lock b/poetry.lock
index f15de57..49ce804 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -154,6 +154,22 @@ files = [
[package.dependencies]
frozenlist = ">=1.1.0"
+[[package]]
+name = "aiosmtplib"
+version = "4.0.0"
+description = "asyncio SMTP client"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "aiosmtplib-4.0.0-py3-none-any.whl", hash =
"sha256:33c72021cd9e9da495823952751e2dd927014a04a0eca711ee4af9812f2f04af"},
+ {file = "aiosmtplib-4.0.0.tar.gz", hash =
"sha256:9629a0d8786ab1e5f790ebbbf5cbe7886fedf949a3f52fd7b27a0360f6233422"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints
(>=1.24.0)", "sphinx-copybutton (>=0.5.0)"]
+uvloop = ["uvloop (>=0.18)"]
+
[[package]]
name = "aiosqlite"
version = "0.21.0"
@@ -2770,4 +2786,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
python-versions = "~=3.13"
-content-hash =
"945a3ec633b734097efa3579b9619948d29ce4cf87092ab812ebc28a324ed68a"
+content-hash =
"19419e871975d018df394bc852c0a913a8f7a5bcc867e795a2f177a668e5cf4c"
diff --git a/pyproject.toml b/pyproject.toml
index d1b5419..d0e6060 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,6 +12,7 @@ requires-python = "~=3.13"
dependencies = [
"aiofiles>=24.1.0,<25.0.0",
"aioshutil (>=1.5,<2.0)",
+ "aiosmtplib (>=4.0.0,<5.0.0)",
"aiosqlite>=0.21.0,<0.22.0",
"alembic~=1.14",
"asfquart @ git+https://github.com/apache/infrastructure-asfquart.git@main",
diff --git a/scripts/poetry/add b/scripts/poetry/add
index d683611..a5de81a 100755
--- a/scripts/poetry/add
+++ b/scripts/poetry/add
@@ -1,2 +1,3 @@
#!/bin/sh
-exec poetry add "$1"
+poetry add "$1"
+poetry lock
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]