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 fa54317 Add a test email task
fa54317 is described below
commit fa5431748d6bda7330e75e5b0d738a6be922b8da
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Mar 5 21:11:08 2025 +0200
Add a test email task
---
atr/mail.py | 220 ++++++++++++++++++++++++++++++++++++++
atr/manager.py | 33 +++++-
atr/routes/__init__.py | 27 +++++
atr/routes/candidate.py | 28 +----
atr/routes/dev.py | 86 +++++++++++++++
atr/routes/download.py | 29 +----
atr/routes/keys.py | 28 +----
atr/routes/package.py | 27 +----
atr/routes/release.py | 30 +-----
atr/server.py | 3 +-
atr/tasks/mailtest.py | 148 +++++++++++++++++++++++++
atr/templates/dev-send-email.html | 153 ++++++++++++++++++++++++++
atr/worker.py | 6 +-
docs/plan.html | 2 +
docs/plan.md | 2 +
poetry.lock | 102 +++++++++++++-----
pyproject.toml | 8 ++
uv.lock | 37 +++++--
18 files changed, 791 insertions(+), 178 deletions(-)
diff --git a/atr/mail.py b/atr/mail.py
new file mode 100644
index 0000000..3e6158e
--- /dev/null
+++ b/atr/mail.py
@@ -0,0 +1,220 @@
+# 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 datetime
+import logging
+import smtplib
+import ssl
+import time
+import uuid
+from email.utils import formatdate
+from io import StringIO
+from typing import Any
+
+import dkim
+import dns.rdtypes.ANY.MX
+import dns.resolver
+
+# TODO: We should choose a pattern for globals
+# We could e.g. use uppercase instead of global_
+# It's not always worth identifying globals as globals
+# But in many cases we should do so
+# TODO: Get at least global_domain from configuration
+# And probably global_dkim_selector too
+global_dkim_selector = "202501"
+global_domain = "tooling-vm-ec2-de.apache.org"
+global_email_contact = f"contact@{global_domain}"
+global_secret_key: str | None = None
+
+
+def set_secret_key(key: str) -> None:
+ """Set the secret key for DKIM signing."""
+ global global_secret_key
+ global_secret_key = key
+
+
+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
+
+
+def split_address(addr: str) -> tuple[str, str]:
+ """Split an email address into local and domain parts."""
+ parts = addr.split("@", 1)
+ if len(parts) != 2:
+ raise ValueError("Invalid mail address")
+ return parts[0], parts[1]
+
+
+def send(event: ArtifactEvent) -> None:
+ """Send an email notification about an artifact."""
+ logging.info(f"Sending email for event: {event}")
+ from_addr = global_email_contact
+ to_addr = event.email_recipient
+ # UUID4 is entirely random, with no timestamp nor namespace
+ # It does have 6 version and variant bits, so only 122 bits are random
+ mid = f"<{uuid.uuid4()}@{global_domain}>"
+ msg_text = f"""
+From: {from_addr}
+To: {to_addr}
+Subject: {event.artifact_name}
+Date: {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
+ msg_text = msg_text.strip().replace("\n", "\r\n") + "\r\n"
+
+ start = time.perf_counter()
+ logging.info(f"sending message: {msg_text}")
+
+ try:
+ send_many(from_addr, [to_addr], msg_text)
+ logging.info(f"sent to {to_addr}")
+ except Exception as e:
+ logging.error(f"send error: {e}")
+
+ elapsed = time.perf_counter() - start
+ logging.info(f" send_many took {elapsed:.3f}s")
+
+
+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")
+
+ if global_secret_key is None:
+ raise ValueError("global_secret_key is not set")
+
+ # DKIM sign the message
+ private_key = bytes(global_secret_key, "utf-8")
+
+ # Create a DKIM signature
+ sig = dkim.sign(
+ message=message_bytes,
+ selector=bytes(global_dkim_selector, "utf-8"),
+ domain=bytes(global_domain, "utf-8"),
+ privkey=private_key,
+ include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"],
+ )
+
+ # Prepend the DKIM signature to the message
+ dkim_msg = sig + message_bytes
+ dkim_reader = StringIO(str(dkim_msg, "utf-8"))
+
+ logging.info("email_send_many")
+
+ for addr in to_addrs:
+ _, domain = split_address(addr)
+
+ if domain == "localhost":
+ mxs = [("127.0.0.1", 0)]
+ else:
+ mxs = resolve_mx_records(domain)
+
+ # Try each MX server
+ errors = []
+ for mx_host, _ in mxs:
+ try:
+ send_one(mx_host, from_addr, addr, dkim_reader)
+ # Success, no need to try other MX servers
+ break
+ except Exception as e:
+ errors.append(f"Failed to send to {mx_host}: {e}")
+ # Reset reader for next attempt
+ dkim_reader.seek(0)
+ else:
+ # If we get here, all MX servers failed
+ raise Exception("; ".join(errors))
+
+
+def resolve_mx_records(domain: str) -> list[tuple[str, int]]:
+ try:
+ # Query MX records
+ mx_records = dns.resolver.resolve(domain, "MX")
+ mxs = []
+
+ for rdata in mx_records:
+ if not isinstance(rdata, dns.rdtypes.ANY.MX.MX):
+ 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
+ mxs.sort(key=lambda x: x[1])
+
+ if not mxs:
+ mxs = [(domain, 0)]
+ except Exception as e:
+ raise ValueError(f"Failed to lookup MX records for {domain}: {e}")
+ return mxs
+
+
+class LoggingSMTP(smtplib.SMTP):
+ def _print_debug(self, *args: Any) -> None:
+ template = ["%s"] * len(args)
+ if self.debuglevel > 1:
+ template.append("%s")
+ logging.info(" ".join(template), datetime.datetime.now().time(),
*args)
+ else:
+ logging.info(" ".join(template), *args)
+
+
+def send_one(mx_host: str, from_addr: str, to_addr: str, msg_reader: StringIO)
-> None:
+ """Send an email to a single recipient via a specific MX server."""
+ default_timeout_seconds = 30
+
+ try:
+ # Connect to the SMTP server
+ logging.info(f"Connecting to {mx_host}:25")
+ smtp = LoggingSMTP(mx_host, 25, timeout=default_timeout_seconds)
+ smtp.set_debuglevel(2)
+
+ # Identify ourselves to the server
+ smtp.ehlo(global_domain)
+
+ # If STARTTLS is available, use it
+ if smtp.has_extn("STARTTLS"):
+ context = ssl.create_default_context()
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
+ smtp.starttls(context=context)
+ # Re-identify after TLS
+ smtp.ehlo(global_domain)
+
+ # Send the message
+ smtp.mail(from_addr)
+ smtp.rcpt(to_addr)
+ smtp.data(msg_reader.read())
+
+ # Close the connection
+ smtp.quit()
+
+ except (OSError, smtplib.SMTPException) as e:
+ raise Exception(f"SMTP error: {e}")
diff --git a/atr/manager.py b/atr/manager.py
index b48d952..a1b1cbb 100644
--- a/atr/manager.py
+++ b/atr/manager.py
@@ -23,6 +23,7 @@ import os
import signal
import sys
from datetime import UTC, datetime
+from io import TextIOWrapper
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -37,6 +38,9 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
+# Global debug flag to control worker process output capturing
+global_worker_debug = False
+
class WorkerProcess:
"""Interface to control a worker process."""
@@ -172,13 +176,30 @@ class WorkerManager:
# Get absolute path to worker script
worker_script = os.path.join(project_root, "atr", "worker.py")
+ # Handle stdout and stderr based on debug setting
+ stdout_target: int | TextIOWrapper = asyncio.subprocess.DEVNULL
+ stderr_target: int | TextIOWrapper = asyncio.subprocess.DEVNULL
+
+ # Generate a unique log file name for this worker if debugging is
enabled
+ log_file_path = None
+ if global_worker_debug:
+ timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
+ log_file_name = f"worker_{timestamp}_{os.getpid()}.log"
+ log_file_path = os.path.join(project_root, "state",
log_file_name)
+
+ # Open log file for writing
+ log_file = await asyncio.to_thread(open, log_file_path, "w")
+ stdout_target = log_file
+ stderr_target = log_file
+ logger.info(f"Worker output will be logged to {log_file_path}")
+
# Start worker process with the updated environment
# Use preexec_fn to create new process group
process = await asyncio.create_subprocess_exec(
sys.executable,
worker_script,
- stdout=asyncio.subprocess.DEVNULL,
- stderr=asyncio.subprocess.DEVNULL,
+ stdout=stdout_target,
+ stderr=stderr_target,
env=env,
preexec_fn=os.setsid,
)
@@ -187,10 +208,12 @@ class WorkerManager:
if worker.pid:
self.workers[worker.pid] = worker
logger.info(f"Started worker process {worker.pid}")
+ if global_worker_debug and log_file_path:
+ logger.info(f"Worker {worker.pid} logs: {log_file_path}")
else:
- # TODO: We should count failures
- # We could perhaps stop the manager over a certain count
- logger.error("Failed to start worker process")
+ logger.error("Failed to start worker process: No PID assigned")
+ if global_worker_debug and isinstance(stdout_target,
TextIOWrapper):
+ await asyncio.to_thread(stdout_target.close)
except Exception as e:
logger.error(f"Error spawning worker: {e}")
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 91e7632..e48f47a 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -23,6 +23,8 @@ from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, ParamSpec, TypeVar
import aiofiles
+from quart import Request
+from werkzeug.datastructures import MultiDict
from asfquart import APP
@@ -247,3 +249,28 @@ def app_route_performance_measure(route_path: str,
http_methods: list[str] | Non
return wrapper
return decorator
+
+
+async def get_form(request: Request) -> MultiDict:
+ # The request.form() method in Quart calls a synchronous tempfile method
+ # It calls quart.wrappers.request.form _load_form_data
+ # Which calls quart.formparser parse and parse_func and parser.parse
+ # Which calls _write which calls tempfile, which is synchronous
+ # It's getting a tempfile back from some prior call
+ # We can't just make blockbuster ignore the call because then it ignores
it everywhere
+ from asfquart import APP
+
+ if APP is ...:
+ raise RuntimeError("APP is not set")
+
+ # Or quart.current_app?
+ blockbuster = APP.config["blockbuster"]
+
+ # Turn blockbuster off
+ if blockbuster is not None:
+ blockbuster.deactivate()
+ form = await request.form
+ # Turn blockbuster on
+ if blockbuster is not None:
+ blockbuster.activate()
+ return form
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 2493c5d..9967784 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -29,7 +29,6 @@ from quart import Request, redirect, render_template,
request, url_for
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlmodel import select
-from werkzeug.datastructures import MultiDict
from werkzeug.wrappers.response import Response
from asfquart import APP
@@ -47,7 +46,7 @@ from atr.db.models import (
ReleaseStage,
Task,
)
-from atr.routes import app_route
+from atr.routes import app_route, get_form
if APP is ...:
raise RuntimeError("APP is not set")
@@ -95,31 +94,6 @@ def format_artifact_name(project_name: str, product_name:
str, version: str, is_
return f"apache-{project_name}-{product_name}-{version}"
-async def get_form(request: Request) -> MultiDict:
- # The request.form() method in Quart calls a synchronous tempfile method
- # It calls quart.wrappers.request.form _load_form_data
- # Which calls quart.formparser parse and parse_func and parser.parse
- # Which calls _write which calls tempfile, which is synchronous
- # It's getting a tempfile back from some prior call
- # We can't just make blockbuster ignore the call because then it ignores
it everywhere
- from asfquart import APP
-
- if APP is ...:
- raise RuntimeError("APP is not set")
-
- # Or quart.current_app?
- blockbuster = APP.config["blockbuster"]
-
- # Turn blockbuster off
- if blockbuster is not None:
- blockbuster.deactivate()
- form = await request.form
- # Turn blockbuster on
- if blockbuster is not None:
- blockbuster.activate()
- return form
-
-
# Release functions
diff --git a/atr/routes/dev.py b/atr/routes/dev.py
new file mode 100644
index 0000000..57990c2
--- /dev/null
+++ b/atr/routes/dev.py
@@ -0,0 +1,86 @@
+# 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.
+
+
+from quart import render_template, request
+from quart.typing import ResponseReturnValue
+
+from asfquart import APP
+from asfquart.auth import Requirements, require
+from asfquart.base import ASFQuartException
+from asfquart.session import read as session_read
+from atr.db import get_session
+from atr.db.models import Task, TaskStatus
+from atr.routes import app_route, get_form
+
+if APP is ...:
+ raise RuntimeError("APP is not set")
+
+
+@app_route("/dev/send-email", methods=["GET", "POST"])
+@require(Requirements.committer)
+async def dev_email_send() -> ResponseReturnValue:
+ """Simple endpoint for testing email functionality."""
+ session = await session_read()
+ if session is None:
+ raise ASFQuartException("Not authenticated", errorcode=401)
+ asf_id = session.uid
+
+ if request.method == "POST":
+ form = await get_form(request)
+
+ email = form.get("email_recipient", "")
+ name = form.get("artifact_name", "")
+ token = form.get("token", "")
+
+ if not email:
+ return await render_template(
+ "dev-send-email.html",
+ asf_id=asf_id,
+ error="Email recipient is required",
+ )
+
+ if not name:
+ return await render_template(
+ "dev-send-email.html",
+ asf_id=asf_id,
+ error="Artifact name is required",
+ )
+
+ # Create a task for mail testing
+ async with get_session() as db_session:
+ async with db_session.begin():
+ task = Task(
+ status=TaskStatus.QUEUED,
+ task_type="mailtest_send",
+ task_args=[name, email, token],
+ )
+ db_session.add(task)
+ # Flush to get the task ID
+ await db_session.flush()
+
+ return await render_template(
+ "dev-send-email.html",
+ asf_id=asf_id,
+ success=True,
+ message=f"Email task queued with ID {task.id}. It will be
processed by a worker.",
+ email_recipient=email,
+ artifact_name=name,
+ token=token,
+ )
+
+ return await render_template("dev-send-email.html", asf_id=asf_id)
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 4d35339..43fb846 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -31,16 +31,15 @@ from typing import cast
import aiofiles
import aiofiles.os
-from quart import Request, flash, redirect, send_file, url_for
+from quart import flash, redirect, send_file, url_for
from quart.wrappers.response import Response as QuartResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlmodel import select
-from werkzeug.datastructures import FileStorage, MultiDict
+from werkzeug.datastructures import FileStorage
from werkzeug.wrappers.response import Response
-from asfquart import APP
from asfquart.auth import Requirements, require
from asfquart.base import ASFQuartException
from asfquart.session import ClientSession
@@ -109,30 +108,6 @@ async def file_hash_save(base_dir: Path, file:
FileStorage) -> tuple[str, int]:
raise e
-async def get_form(request: Request) -> MultiDict:
- # The request.form() method in Quart calls a synchronous tempfile method
- # It calls quart.wrappers.request.form _load_form_data
- # Which calls quart.formparser parse and parse_func and parser.parse
- # Which calls _write which calls tempfile, which is synchronous
- # It's getting a tempfile back from some prior call
- # We can't just make blockbuster ignore the call because then it ignores
it everywhere
-
- if APP is ...:
- raise RuntimeError("APP is not set")
-
- # Or quart.current_app?
- blockbuster = APP.config["blockbuster"]
-
- # Turn blockbuster off
- if blockbuster is not None:
- blockbuster.deactivate()
- form = await request.form
- # Turn blockbuster on
- if blockbuster is not None:
- blockbuster.activate()
- return form
-
-
async def key_user_session_add(
session: ClientSession,
public_key: str,
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index ecc514b..7946cb2 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -34,10 +34,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlmodel import select
-from werkzeug.datastructures import MultiDict
from werkzeug.wrappers.response import Response
-from asfquart import APP
from asfquart.auth import Requirements, require
from asfquart.base import ASFQuartException
from asfquart.session import ClientSession
@@ -48,7 +46,7 @@ from atr.db.models import (
PMCKeyLink,
PublicSigningKey,
)
-from atr.routes import FlashError, algorithms, app_route
+from atr.routes import FlashError, algorithms, app_route, get_form
@asynccontextmanager
@@ -63,30 +61,6 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
await asyncio.to_thread(shutil.rmtree, temp_dir)
-async def get_form(request: Request) -> MultiDict:
- # The request.form() method in Quart calls a synchronous tempfile method
- # It calls quart.wrappers.request.form _load_form_data
- # Which calls quart.formparser parse and parse_func and parser.parse
- # Which calls _write which calls tempfile, which is synchronous
- # It's getting a tempfile back from some prior call
- # We can't just make blockbuster ignore the call because then it ignores
it everywhere
-
- if APP is ...:
- raise RuntimeError("APP is not set")
-
- # Or quart.current_app?
- blockbuster = APP.config["blockbuster"]
-
- # Turn blockbuster off
- if blockbuster is not None:
- blockbuster.deactivate()
- form = await request.form
- # Turn blockbuster on
- if blockbuster is not None:
- blockbuster.activate()
- return form
-
-
async def key_add_post(session: ClientSession, request: Request, user_pmcs:
Sequence[PMC]) -> dict | None:
form = await get_form(request)
public_key = form.get("public_key")
diff --git a/atr/routes/package.py b/atr/routes/package.py
index 0670247..d6f35f4 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -37,7 +37,6 @@ from sqlmodel import select
from werkzeug.datastructures import FileStorage, MultiDict
from werkzeug.wrappers.response import Response
-from asfquart import APP
from asfquart.auth import Requirements, require
from asfquart.base import ASFQuartException
from asfquart.session import read as session_read
@@ -51,7 +50,7 @@ from atr.db.models import (
Task,
TaskStatus,
)
-from atr.routes import FlashError, app_route
+from atr.routes import FlashError, app_route, get_form
from atr.util import compute_sha512, get_release_storage_dir
@@ -114,30 +113,6 @@ def format_file_size(size_in_bytes: int) -> str:
return f"{formatted_bytes} bytes"
-async def get_form(request: Request) -> MultiDict:
- # The request.form() method in Quart calls a synchronous tempfile method
- # It calls quart.wrappers.request.form _load_form_data
- # Which calls quart.formparser parse and parse_func and parser.parse
- # Which calls _write which calls tempfile, which is synchronous
- # It's getting a tempfile back from some prior call
- # We can't just make blockbuster ignore the call because then it ignores
it everywhere
-
- if APP is ...:
- raise RuntimeError("APP is not set")
-
- # Or quart.current_app?
- blockbuster = APP.config["blockbuster"]
-
- # Turn blockbuster off
- if blockbuster is not None:
- blockbuster.deactivate()
- form = await request.form
- # Turn blockbuster on
- if blockbuster is not None:
- blockbuster.activate()
- return form
-
-
# Package functions
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 082e25b..a906549 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -24,12 +24,11 @@ from typing import cast
import aiofiles
import aiofiles.os
-from quart import Request, flash, redirect, render_template, request, url_for
+from quart import flash, redirect, render_template, request, url_for
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlmodel import select
-from werkzeug.datastructures import MultiDict
from werkzeug.wrappers.response import Response
from asfquart import APP
@@ -44,38 +43,13 @@ from atr.db.models import (
Task,
TaskStatus,
)
-from atr.routes import FlashError, app_route
+from atr.routes import FlashError, app_route, get_form
from atr.util import get_release_storage_dir
if APP is ...:
raise RuntimeError("APP is not set")
-async def get_form(request: Request) -> MultiDict:
- # The request.form() method in Quart calls a synchronous tempfile method
- # It calls quart.wrappers.request.form _load_form_data
- # Which calls quart.formparser parse and parse_func and parser.parse
- # Which calls _write which calls tempfile, which is synchronous
- # It's getting a tempfile back from some prior call
- # We can't just make blockbuster ignore the call because then it ignores
it everywhere
- from asfquart import APP
-
- if APP is ...:
- raise RuntimeError("APP is not set")
-
- # Or quart.current_app?
- blockbuster = APP.config["blockbuster"]
-
- # Turn blockbuster off
- if blockbuster is not None:
- blockbuster.deactivate()
- form = await request.form
- # Turn blockbuster on
- if blockbuster is not None:
- blockbuster.activate()
- return form
-
-
# Package functions
diff --git a/atr/server.py b/atr/server.py
index a9cc508..a6fdc99 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -63,11 +63,12 @@ class ApiOnlyOpenAPIProvider(OpenAPIProvider):
def register_routes() -> tuple[str, ...]:
- from atr.routes import candidate, docs, download, keys, package, project,
release, root
+ from atr.routes import candidate, dev, docs, download, keys, package,
project, release, root
# Must do this otherwise ruff "fixes" this function by removing the imports
return (
candidate.__name__,
+ dev.__name__,
docs.__name__,
download.__name__,
keys.__name__,
diff --git a/atr/tasks/mailtest.py b/atr/tasks/mailtest.py
new file mode 100644
index 0000000..1b362d0
--- /dev/null
+++ b/atr/tasks/mailtest.py
@@ -0,0 +1,148 @@
+# 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 logging
+import os
+from dataclasses import dataclass
+from typing import Any
+
+# Configure detailed logging
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.DEBUG)
+
+# Create file handler for test.log
+file_handler = logging.FileHandler("tasks-mailtest.log")
+file_handler.setLevel(logging.DEBUG)
+
+# Create formatter with detailed information
+formatter = 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",
+)
+file_handler.setFormatter(formatter)
+logger.addHandler(file_handler)
+# Ensure parent loggers don't duplicate messages
+logger.propagate = False
+
+logger.info("Mail test module imported")
+
+
+@dataclass
+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[str, 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 "FAILED", str(e), tuple()
+
+
+def send_core(args_list: list[str]) -> tuple[str, str | None, tuple[Any, ...]]:
+ """Send a test email."""
+ import atr.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(file_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}"
+ )
+
+ # 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 "FAILED", error_msg, tuple()
+
+ event = atr.mail.ArtifactEvent(
+ artifact_name=args.artifact_name,
+ email_recipient=args.email_recipient,
+ token=args.token,
+ )
+ atr.mail.send(event)
+ logger.info(f"Email sent successfully to {args.email_recipient}")
+
+ return "COMPLETED", None, tuple()
+
+ except Exception as e:
+ logger.exception(f"Error in send_core: {e}")
+ return "FAILED", str(e), tuple()
diff --git a/atr/templates/dev-send-email.html
b/atr/templates/dev-send-email.html
new file mode 100644
index 0000000..eaaff3f
--- /dev/null
+++ b/atr/templates/dev-send-email.html
@@ -0,0 +1,153 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Email testing ~ ATR
+{% endblock title %}
+
+{% block description %}
+ Test email sending functionality.
+{% endblock description %}
+
+{% block stylesheets %}
+ {{ super() }}
+ <style>
+ .form-table {
+ width: 100%;
+ }
+
+ .form-table th {
+ width: 200px;
+ text-align: right;
+ padding-right: 1rem;
+ vertical-align: top;
+ font-weight: 500;
+ }
+
+ .form-table td {
+ vertical-align: top;
+ }
+
+ .form-table label {
+ border-bottom: none;
+ padding-bottom: 0;
+ }
+
+ input[type="text"],
+ input[type="email"] {
+ width: 100%;
+ max-width: 600px;
+ padding: 0.375rem;
+ border: 1px solid #ced4da;
+ border-radius: 0.25rem;
+ }
+
+ .help-text {
+ color: #666;
+ font-size: 0.9em;
+ display: block;
+ margin-top: 0.25rem;
+ }
+
+ .error-message {
+ color: #dc3545;
+ margin-top: 0.25rem;
+ }
+
+ .success-message {
+ color: #28a745;
+ margin-top: 0.25rem;
+ }
+
+ button {
+ margin-top: 1rem;
+ }
+ </style>
+{% endblock stylesheets %}
+
+{% block content %}
+ <h1>Test email sending</h1>
+ <p class="intro">
+ Welcome, <strong>{{ asf_id }}</strong>! Use this form to test the email
sending functionality.
+ </p>
+
+ {% if error %}
+ <div class="error-message">
+ <p>
+ <strong>Error:</strong> {{ error }}
+ </p>
+ </div>
+ {% endif %}
+
+ {% if success %}
+ <div class="success-message">
+ <p>
+ <strong>Success!</strong>
+ {% if message %}
+ {{ message }}
+ {% else %}
+ Email was sent successfully.
+ {% endif %}
+ </p>
+ </div>
+ {% endif %}
+
+ <form method="post" class="striking">
+ <table class="form-table">
+ <tbody>
+ <tr>
+ <th>
+ <label for="email_recipient">Recipient email:</label>
+ </th>
+ <td>
+ <input type="email"
+ id="email_recipient"
+ name="email_recipient"
+ required
+ value="{{ email_recipient or '' }}"
+ placeholder="[email protected]"
+ aria-describedby="email-help" />
+ <span id="email-help" class="help-text">Enter the email address to
send the test email to</span>
+ </td>
+ </tr>
+
+ <tr>
+ <th>
+ <label for="artifact_name">Artifact name:</label>
+ </th>
+ <td>
+ <input type="text"
+ id="artifact_name"
+ name="artifact_name"
+ required
+ value="{{ artifact_name or '' }}"
+ placeholder="my-artifact-1.0.0"
+ aria-describedby="artifact-help" />
+ <span id="artifact-help" class="help-text">Enter a name for the
artifact</span>
+ </td>
+ </tr>
+
+ <tr>
+ <th>
+ <label for="token">Token (optional):</label>
+ </th>
+ <td>
+ <input type="text"
+ id="token"
+ name="token"
+ value="{{ token or '' }}"
+ placeholder="Optional token"
+ aria-describedby="token-help" />
+ <span id="token-help" class="help-text">Optional token to include
in the email</span>
+ </td>
+ </tr>
+
+ <tr>
+ <td></td>
+ <td>
+ <button type="submit">Send test email</button>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </form>
+{% endblock content %}
diff --git a/atr/worker.py b/atr/worker.py
index f5bb158..dc8b315 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -388,6 +388,7 @@ def task_process(task_id: int, task_type: str, task_args:
str) -> None:
# TODO: This does not go here permanently
# We need to move the other tasks into atr.tasks
from atr.tasks.bulk import download as bulk_download
+ from atr.tasks.mailtest import send as mailtest_send
logger.info(f"Processing task {task_id} ({task_type}) with args
{task_args}")
try:
@@ -404,11 +405,12 @@ def task_process(task_id: int, task_type: str, task_args:
str) -> None:
"verify_rat_license": task_verify_rat_license,
"generate_cyclonedx_sbom": task_generate_cyclonedx_sbom,
"package_bulk_download": bulk_download,
+ "mailtest_send": mailtest_send,
}
handler = task_handlers.get(task_type)
if not handler:
- msg = f"Unknown task type: {task_type}"
+ msg = f"Unknown task type: {task_type}, {task_handlers.keys()}"
logger.error(msg)
raise Exception(msg)
@@ -478,6 +480,8 @@ def worker_signal_handle(signum: int, frame: object) ->
None:
if __name__ == "__main__":
+ logger.info("Starting ATR worker...")
+ print("Starting ATR worker...")
try:
main()
except Exception as e:
diff --git a/docs/plan.html b/docs/plan.html
index 2ea18d7..df91dcb 100644
--- a/docs/plan.html
+++ b/docs/plan.html
@@ -113,6 +113,8 @@
<li>[DONE] Fix and improve the package checks summary count</li>
<li>Ensure that all errors are caught and logged or displayed</li>
<li>Add tests</li>
+<li>Improve the proprietary platform patch in ASFQuart and submit upstream</li>
+<li>Patch the synchronous behaviour in Jinja and submit upstream</li>
</ul>
</li>
<li>
diff --git a/docs/plan.md b/docs/plan.md
index 3e67245..56959aa 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -86,6 +86,8 @@ Advanced tasks, possibly deferred
- [DONE] Fix and improve the package checks summary count
- Ensure that all errors are caught and logged or displayed
- Add tests
+ - Improve the proprietary platform patch in ASFQuart and submit upstream
+ - Patch the synchronous behaviour in Jinja and submit upstream
2. Ensure that performance is optimal
- [DONE] Add page load timing metrics to a log
diff --git a/poetry.lock b/poetry.lock
index cdb0165..9807fe6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 2.1.1 and should not be
changed by hand.
+# This file is automatically @generated by Poetry 2.0.1 and should not be
changed by hand.
[[package]]
name = "aiofiles"
@@ -14,14 +14,14 @@ files = [
[[package]]
name = "aiohappyeyeballs"
-version = "2.4.6"
+version = "2.4.8"
description = "Happy Eyeballs for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "aiohappyeyeballs-2.4.6-py3-none-any.whl", hash =
"sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1"},
- {file = "aiohappyeyeballs-2.4.6.tar.gz", hash =
"sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0"},
+ {file = "aiohappyeyeballs-2.4.8-py3-none-any.whl", hash =
"sha256:6cac4f5dd6e34a9644e69cf9021ef679e4394f54e58a183056d12009e42ea9e3"},
+ {file = "aiohappyeyeballs-2.4.8.tar.gz", hash =
"sha256:19728772cb12263077982d2f55453babd8bec6a052a926cd5c0c42796da8bf62"},
]
[[package]]
@@ -125,7 +125,7 @@ propcache = ">=0.2.0"
yarl = ">=1.17.0,<2.0"
[package.extras]
-speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns
(>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"",
"brotlicffi ; platform_python_implementation != \"CPython\""]
+speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
[[package]]
name = "aiosignal"
@@ -163,23 +163,23 @@ docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.1)"]
[[package]]
name = "alembic"
-version = "1.14.1"
+version = "1.15.1"
description = "A database migration tool for SQLAlchemy."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "alembic-1.14.1-py3-none-any.whl", hash =
"sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5"},
- {file = "alembic-1.14.1.tar.gz", hash =
"sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213"},
+ {file = "alembic-1.15.1-py3-none-any.whl", hash =
"sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe"},
+ {file = "alembic-1.15.1.tar.gz", hash =
"sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49"},
]
[package.dependencies]
Mako = "*"
-SQLAlchemy = ">=1.3.0"
-typing-extensions = ">=4"
+SQLAlchemy = ">=1.4.0"
+typing-extensions = ">=4.12"
[package.extras]
-tz = ["backports.zoneinfo ; python_version < \"3.9\"", "tzdata"]
+tz = ["tzdata"]
[[package]]
name = "annotated-types"
@@ -211,7 +211,7 @@ sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints
(>=1.2.0)", "sphinx_rtd_theme"]
-test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)",
"hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme",
"truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ;
platform_python_implementation == \"CPython\" and platform_system !=
\"Windows\" and python_version < \"3.14\""]
+test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)",
"hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme",
"truststore (>=0.9.1)", "uvloop (>=0.21)"]
trio = ["trio (>=0.26.1)"]
[[package]]
@@ -284,12 +284,12 @@ files = [
]
[package.extras]
-benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"",
"hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\"
and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)",
"pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation ==
\"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-cov = ["cloudpickle ; platform_python_implementation == \"CPython\"",
"coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ;
platform_python_implementation == \"CPython\" and python_version >= \"3.10\"",
"pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ;
platform_python_implementation == \"CPython\" and python_version >= \"3.10\"",
"pytest-xdist[psutil]"]
-dev = ["cloudpickle ; platform_python_implementation == \"CPython\"",
"hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\"
and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest
(>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation ==
\"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"]
+benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler",
"pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins",
"pytest-xdist[psutil]"]
+cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy
(>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins",
"pytest-xdist[psutil]"]
+dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv",
"pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page",
"sphinxcontrib-towncrier", "towncrier (<24.7)"]
-tests = ["cloudpickle ; platform_python_implementation == \"CPython\"",
"hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\"
and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)",
"pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and
python_version >= \"3.10\"", "pytest-xdist[psutil]"]
-tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\"
and python_version >= \"3.10\"", "pytest-mypy-plugins ;
platform_python_implementation == \"CPython\" and python_version >= \"3.10\""]
+tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest
(>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
+tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
[[package]]
name = "blinker"
@@ -602,10 +602,10 @@ files = [
cffi = {version = ">=1.12", markers = "platform_python_implementation !=
\"PyPy\""}
[package.extras]
-docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >=
\"3.8\""]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)",
"sphinxcontrib-spelling (>=7.3.1)"]
-nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
-pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)",
"mypy (>=1.4)", "ruff (>=0.3.6)"]
+nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"]
+pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend
(>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov
(>=2.10.1)", "pytest-xdist (>=3.5.0)"]
@@ -683,6 +683,52 @@ pyyaml = ">=6"
regex = ">=2023"
tqdm = ">=4.62.2"
+[[package]]
+name = "dkimpy"
+version = "1.1.9+sbp1"
+description = "DKIM (DomainKeys Identified Mail), ARC (Authenticated Receive
Chain), and TLSRPT (TLS Report) email signing and verification"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = []
+develop = false
+
+[package.dependencies]
+dnspython = ">=2.0.0"
+
+[package.extras]
+arc = ["authres"]
+asyncio = ["aiodns"]
+ed25519 = ["pynacl"]
+testing = ["authres", "pynacl"]
+
+[package.source]
+type = "git"
+url = "https://github.com/sbp/dkimpy.git"
+reference = "main"
+resolved_reference = "7ed6f94a94719a804bc8351782dbdb77d75b029f"
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+description = "DNS toolkit"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "dnspython-2.7.0-py3-none-any.whl", hash =
"sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"},
+ {file = "dnspython-2.7.0.tar.gz", hash =
"sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"},
+]
+
+[package.extras]
+dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn
(>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov
(>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme
(>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"]
+dnssec = ["cryptography (>=43)"]
+doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"]
+doq = ["aioquic (>=1.0.0)"]
+idna = ["idna (>=3.7)"]
+trio = ["trio (>=0.23)"]
+wmi = ["wmi (>=1.5.1)"]
+
[[package]]
name = "dunamai"
version = "1.23.0"
@@ -748,7 +794,7 @@ files = [
[package.extras]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints
(>=3)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover
(>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov
(>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv
(>=20.28.1)"]
-typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
+typing = ["typing-extensions (>=4.12.2)"]
[[package]]
name = "flask"
@@ -1054,7 +1100,7 @@ httpcore = "==1.*"
idna = "*"
[package.extras]
-brotli = ["brotli ; platform_python_implementation == \"CPython\"",
"brotlicffi ; platform_python_implementation != \"CPython\""]
+brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
@@ -1082,7 +1128,7 @@ wsproto = ">=0.14.0"
docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"]
h3 = ["aioquic (>=0.9.0,<1.0)"]
trio = ["trio (>=0.22.0)"]
-uvloop = ["uvloop (>=0.18) ; platform_system != \"Windows\""]
+uvloop = ["uvloop (>=0.18)"]
[[package]]
name = "hyperframe"
@@ -1697,7 +1743,7 @@ typing-extensions = ">=4.12.2"
[package.extras]
email = ["email-validator (>=2.0.0)"]
-timezone = ["tzdata ; python_version >= \"3.9\" and platform_system ==
\"Windows\""]
+timezone = ["tzdata"]
[[package]]
name = "pydantic-core"
@@ -2368,7 +2414,7 @@ files = [
]
[package.extras]
-brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"",
"brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
@@ -2392,7 +2438,7 @@ platformdirs = ">=3.9.1,<5"
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)",
"sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier
(>=23.6)"]
-test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)",
"coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)",
"pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ;
platform_python_implementation == \"PyPy\" or platform_python_implementation ==
\"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"",
"pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)",
"setuptools (>=68)", "time-machine (>=2.10) ; platform_pyth [...]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)",
"coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)",
"pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)",
"pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)",
"setuptools (>=68)", "time-machine (>=2.10)"]
[[package]]
name = "watchfiles"
@@ -2623,4 +2669,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
python-versions = "~=3.13"
-content-hash =
"4299d87c5aabfe30a4a23876a3e58bf06891aaeecebaedecdcc9d970b94fdbf8"
+content-hash =
"a79eacf4cc9156bb1395aeda139734f51b9de6b6efc2f64587197bbb80f882a8"
diff --git a/pyproject.toml b/pyproject.toml
index 1db2632..250b63f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,6 +8,7 @@ authors = [
license = "Apache-2.0"
readme = "README.md"
requires-python = "~=3.13"
+# https://bugs.launchpad.net/dkimpy/+bug/2024461
dependencies = [
"aiofiles>=24.1.0,<25.0.0",
"aiosqlite>=0.21.0,<0.22.0",
@@ -23,6 +24,9 @@ dependencies = [
"python-gnupg~=0.5",
"quart-schema~=0.21",
"sqlmodel~=0.0",
+ "dnspython>=2.7.0,<3.0.0",
+ # "dkimpy>=1.1.8,<2.0.0", # Using GitHub fork instead
+ "dkimpy @ git+https://github.com/sbp/dkimpy.git@main",
]
[dependency-groups]
@@ -53,6 +57,9 @@ package-mode = false
[tool.poetry.dependencies]
asfquart = { path = "./asfquart", develop = true }
python = "~=3.13"
+# dnspython = ">=2.7.0,<3.0.0"
+# dkimpy = { version = ">=1.1.8,<2.0.0", extras = ["dnspython"] }
+# dkimpy = { git =
"https://github.com/sbp/dkimpy.git#subdirectory=src/dkimpy", branch = "main" }
[tool.poetry.group.test.dependencies]
pytest = ">=8.0"
@@ -70,6 +77,7 @@ types-aiofiles = ">=24.1.0.20241221,<25.0.0.0"
[tool.uv.sources]
asfquart = { path = "./asfquart", editable = true }
+# dkimpy = { git = "https://github.com/sbp/dkimpy.git", branch = "main" }
# Additional tools
diff --git a/uv.lock b/uv.lock
index fb8b667..c5d47e8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -12,11 +12,11 @@ wheels = [
[[package]]
name = "aiohappyeyeballs"
-version = "2.4.6"
+version = "2.4.8"
source = { registry = "https://pypi.org/simple" }
-sdist = { url =
"https://files.pythonhosted.org/packages/08/07/508f9ebba367fc3370162e53a3cfd12f5652ad79f0e0bfdf9f9847c6f159/aiohappyeyeballs-2.4.6.tar.gz",
hash =
"sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0", size
= 21726 }
+sdist = { url =
"https://files.pythonhosted.org/packages/de/7c/79a15272e88d2563c9d63599fa59f05778975f35b255bf8f90c8b12b4ada/aiohappyeyeballs-2.4.8.tar.gz",
hash =
"sha256:19728772cb12263077982d2f55453babd8bec6a052a926cd5c0c42796da8bf62", size
= 22337 }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/44/4c/03fb05f56551828ec67ceb3665e5dc51638042d204983a03b0a1541475b6/aiohappyeyeballs-2.4.6-py3-none-any.whl",
hash =
"sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", size
= 14543 },
+ { url =
"https://files.pythonhosted.org/packages/52/0e/b187e2bb3eeb2644515109657c4474d65a84e7123de249bf1e8467d04a65/aiohappyeyeballs-2.4.8-py3-none-any.whl",
hash =
"sha256:6cac4f5dd6e34a9644e69cf9021ef679e4394f54e58a183056d12009e42ea9e3", size
= 15005 },
]
[[package]]
@@ -78,16 +78,16 @@ wheels = [
[[package]]
name = "alembic"
-version = "1.14.1"
+version = "1.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
-sdist = { url =
"https://files.pythonhosted.org/packages/99/09/f844822e4e847a3f0bd41797f93c4674cd4d2462a3f6c459aa528cdf786e/alembic-1.14.1.tar.gz",
hash =
"sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213", size
= 1918219 }
+sdist = { url =
"https://files.pythonhosted.org/packages/4a/ed/901044acb892caa5604bf818d2da9ab0df94ef606c6059fdf367894ebf60/alembic-1.15.1.tar.gz",
hash =
"sha256:e1a1c738577bca1f27e68728c910cd389b9a92152ff91d902da649c192e30c49", size
= 1924789 }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/54/7e/ac0991d1745f7d755fc1cd381b3990a45b404b4d008fc75e2a983516fbfe/alembic-1.14.1-py3-none-any.whl",
hash =
"sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", size
= 233565 },
+ { url =
"https://files.pythonhosted.org/packages/99/f7/d398fae160568472ddce0b3fde9c4581afc593019a6adc91006a66406991/alembic-1.15.1-py3-none-any.whl",
hash =
"sha256:197de710da4b3e91cf66a826a5b31b5d59a127ab41bd0fc42863e2902ce2bbbe", size
= 231753 },
]
[[package]]
@@ -259,7 +259,7 @@ name = "click"
version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "platform_system == 'Windows'" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url =
"https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz",
hash =
"sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size
= 226593 }
wheels = [
@@ -357,6 +357,23 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl",
hash =
"sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size
= 52290 },
]
+[[package]]
+name = "dkimpy"
+version = "1.1.9+sbp1"
+source = { git =
"https://github.com/sbp/dkimpy.git?rev=main#7ed6f94a94719a804bc8351782dbdb77d75b029f"
}
+dependencies = [
+ { name = "dnspython" },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz",
hash =
"sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size
= 345197 }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl",
hash =
"sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size
= 313632 },
+]
+
[[package]]
name = "dunamai"
version = "1.23.0"
@@ -1119,6 +1136,8 @@ dependencies = [
{ name = "asfquart" },
{ name = "blockbuster" },
{ name = "cryptography" },
+ { name = "dkimpy" },
+ { name = "dnspython" },
{ name = "dunamai" },
{ name = "greenlet" },
{ name = "httpx" },
@@ -1151,6 +1170,8 @@ requires-dist = [
{ name = "asfquart", editable = "asfquart" },
{ name = "blockbuster", specifier = ">=1.5.23,<2.0.0" },
{ name = "cryptography", specifier = "~=44.0" },
+ { name = "dkimpy", git = "https://github.com/sbp/dkimpy.git?rev=main" },
+ { name = "dnspython", specifier = ">=2.7.0,<3.0.0" },
{ name = "dunamai", specifier = ">=1.23.0" },
{ name = "greenlet", specifier = ">=3.1.1,<4.0.0" },
{ name = "httpx", specifier = "~=0.27" },
@@ -1180,7 +1201,7 @@ name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "colorama", marker = "platform_system == 'Windows'" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url =
"https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz",
hash =
"sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size
= 169737 }
wheels = [
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]