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 8322773 Allow check result ignores to be added, and use them in some
of the UI
8322773 is described below
commit 832277333556141d1f8c11469d0acb67e783359f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Jul 29 20:44:36 2025 +0100
Allow check result ignores to be added, and use them in some of the UI
---
atr/blueprints/admin/admin.py | 1 +
atr/blueprints/api/api.py | 29 ++++++++++
atr/db/__init__.py | 32 +++++++++++
atr/db/interaction.py | 63 ++++++++++++++++++++-
atr/models/api.py | 22 +++++++-
atr/models/sql.py | 2 +-
atr/routes/report.py | 14 ++++-
atr/storage/__init__.py | 62 +++++++++++++++++++--
atr/storage/writers/__init__.py | 3 +-
atr/storage/writers/checks.py | 119 ++++++++++++++++++++++++++++++++++++++++
atr/storage/writers/keys.py | 21 ++++++-
11 files changed, 356 insertions(+), 12 deletions(-)
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index a839398..1b69691 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -230,6 +230,7 @@ async def admin_data(model: str = "Committee") -> str:
# TODO: Add distribution channel, key link, and any others
model_methods: dict[str, Callable[[], db.Query[Any]]] = {
"CheckResult": data.check_result,
+ "CheckResultIgnore": data.check_result_ignore,
"Committee": data.committee,
"Project": data.project,
"PublicSigningKey": data.public_signing_key,
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 80313a7..ac252d1 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -64,6 +64,35 @@ import atr.util as util
DictResponse = tuple[dict[str, Any], int]
[email protected]("/checks/ignore/add", methods=["POST"])
[email protected]
+@quart_schema.security_scheme([{"BearerAuth": []}])
+@quart_schema.validate_request(models.api.ChecksIgnoreAddArgs)
+@quart_schema.validate_response(models.api.ChecksIgnoreAddResults, 200)
+async def checks_ignore_add(data: models.api.ChecksIgnoreAddArgs) ->
DictResponse:
+ """
+ Add a check ignore.
+ """
+ asf_uid = _jwt_asf_uid()
+ if not any(data.model_dump().values()):
+ raise exceptions.BadRequest("At least one field must be provided")
+ async with storage.write(asf_uid) as write:
+ wacm = write.as_committee_member(data.committee_name)
+ await wacm.checks.ignore_add(
+ data.release_glob,
+ data.revision_number,
+ data.checker_glob,
+ data.primary_rel_path_glob,
+ data.member_rel_path_glob,
+ data.status,
+ data.message_glob,
+ )
+ return models.api.ChecksIgnoreAddResults(
+ endpoint="/checks/ignore/add",
+ success=True,
+ ).model_dump(), 200
+
+
@api.BLUEPRINT.route("/checks/list/<project>/<version>")
@quart_schema.validate_response(models.api.ChecksListResults, 200)
async def checks_list(project: str, version: str) -> DictResponse:
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 1d1a5d2..d5308a8 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -195,6 +195,38 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
return Query(self, query)
+ def check_result_ignore(
+ self,
+ committee_name: Opt[str] = NOT_SET,
+ release_glob: Opt[str] = NOT_SET,
+ revision_number: Opt[str] = NOT_SET,
+ checker_glob: Opt[str] = NOT_SET,
+ primary_rel_path_glob: Opt[str] = NOT_SET,
+ member_rel_path_glob: Opt[str] = NOT_SET,
+ status: Opt[sql.CheckResultStatusIgnore] = NOT_SET,
+ message_glob: Opt[str] = NOT_SET,
+ ) -> Query[sql.CheckResultIgnore]:
+ query = sqlmodel.select(sql.CheckResultIgnore)
+
+ if is_defined(committee_name):
+ query = query.where(sql.CheckResultIgnore.committee_name ==
committee_name)
+ if is_defined(release_glob):
+ query = query.where(sql.CheckResultIgnore.release_glob ==
release_glob)
+ if is_defined(revision_number):
+ query = query.where(sql.CheckResultIgnore.revision_number ==
revision_number)
+ if is_defined(checker_glob):
+ query = query.where(sql.CheckResultIgnore.checker_glob ==
checker_glob)
+ if is_defined(primary_rel_path_glob):
+ query = query.where(sql.CheckResultIgnore.primary_rel_path_glob ==
primary_rel_path_glob)
+ if is_defined(member_rel_path_glob):
+ query = query.where(sql.CheckResultIgnore.member_rel_path_glob ==
member_rel_path_glob)
+ if is_defined(status):
+ query = query.where(sql.CheckResultIgnore.status == status)
+ if is_defined(message_glob):
+ query = query.where(sql.CheckResultIgnore.message_glob ==
message_glob)
+
+ return Query(self, query)
+
def committee(
self,
name: Opt[str] = NOT_SET,
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 337b1dc..38f6c38 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -18,7 +18,7 @@
import contextlib
import pathlib
import re
-from collections.abc import AsyncGenerator, Sequence
+from collections.abc import AsyncGenerator, Callable, Sequence
import aiofiles.os
import aioshutil
@@ -103,6 +103,25 @@ async def latest_revision(release: sql.Release) ->
sql.Revision | None:
return await data.revision(release_name=release.name,
number=release.latest_revision_number).get()
+async def check_ignores_matcher(
+ committee_name: str,
+ data: db.Session | None = None,
+) -> Callable[[sql.CheckResult], bool]:
+ async with db.ensure_session(data) as data:
+ ignores = await data.check_result_ignore(
+ committee_name=committee_name,
+ ).all()
+
+ def match(cr: sql.CheckResult) -> bool:
+ for ignore in ignores:
+ if _check_ignore_match(cr, ignore):
+ # log.info(f"Ignoring check result {cr} due to ignore
{ignore}")
+ return True
+ return False
+
+ return match
+
+
async def path_info(release: sql.Release, paths: list[pathlib.Path]) ->
PathInfo | None:
info = PathInfo()
latest_revision_number = release.latest_revision_number
@@ -296,6 +315,48 @@ async def user_committees_participant(asf_uid: str,
caller_data: db.Session | No
return await data.committee(has_participant=asf_uid).all()
+def _check_ignore_match(cr: sql.CheckResult, cri: sql.CheckResultIgnore) ->
bool:
+ # Does not check that the committee name matches
+ if cr.status == sql.CheckResultStatus.SUCCESS:
+ # Successes are never ignored
+ return False
+ if cri.release_glob is not None:
+ if not _check_ignore_match_glob(cri.release_glob, cr.release_name):
+ return False
+ if cri.revision_number is not None:
+ if cri.revision_number != cr.revision_number:
+ return False
+ if cri.checker_glob is not None:
+ if not _check_ignore_match_glob(cri.checker_glob, cr.checker):
+ return False
+ return _check_ignore_match_2(cr, cri)
+
+
+def _check_ignore_match_2(cr: sql.CheckResult, cri: sql.CheckResultIgnore) ->
bool:
+ if cri.primary_rel_path_glob is not None:
+ if not _check_ignore_match_glob(cri.primary_rel_path_glob,
cr.primary_rel_path):
+ return False
+ if cri.member_rel_path_glob is not None:
+ if not _check_ignore_match_glob(cri.member_rel_path_glob,
cr.member_rel_path):
+ return False
+ if cri.status is not None:
+ if cr.status != cri.status:
+ return False
+ if cri.message_glob is not None:
+ if not _check_ignore_match_glob(cri.message_glob, cr.message):
+ return False
+ return True
+
+
+def _check_ignore_match_glob(glob: str | None, value: str | None) -> bool:
+ if (glob is None) or (value is None):
+ return False
+ pattern = re.escape(glob).replace(r"\*", ".*")
+ # Should also handle ^ and $
+ # And maybe .replace(r"\?", ".?")
+ return re.match(pattern, value) is not None
+
+
async def _delete_release_data_downloads(release: sql.Release) -> None:
# Delete hard links from the downloads directory
finished_dir = util.release_directory(release)
diff --git a/atr/models/api.py b/atr/models/api.py
index 414a11d..009ae07 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -34,6 +34,24 @@ class ResultsTypeError(TypeError):
pass
+class ChecksIgnoreAddArgs(schema.Strict):
+ committee_name: str = schema.Field(..., **example("example"))
+ release_glob: str | None = schema.Field(default=None,
**example("example-0.0.*"))
+ revision_number: str | None = schema.Field(default=None,
**example("00001"))
+ checker_glob: str | None = schema.Field(default=None,
**example("atr.tasks.checks.license.files"))
+ primary_rel_path_glob: str | None = schema.Field(default=None,
**example("apache-example-0.0.1-*.tar.gz"))
+ member_rel_path_glob: str | None = schema.Field(default=None,
**example("apache-example-0.0.1/*.xml"))
+ status: sql.CheckResultStatusIgnore | None = schema.Field(
+ default=None, **example(sql.CheckResultStatusIgnore.FAILURE)
+ )
+ message_glob: str | None = schema.Field(default=None, **example("sha512
matches for apache-example-0.0.1/*.xml"))
+
+
+class ChecksIgnoreAddResults(schema.Strict):
+ endpoint: Literal["/checks/ignore/add"] = schema.Field(alias="endpoint")
+ success: Literal[True] = schema.Field(..., **example(True))
+
+
class ChecksListResults(schema.Strict):
endpoint: Literal["/checks/list"] = schema.Field(alias="endpoint")
checks: Sequence[sql.CheckResult]
@@ -381,7 +399,8 @@ class VoteTabulateResults(schema.Strict):
# This is for *Results classes only
# We do NOT put *Args classes here
Results = Annotated[
- ChecksListResults
+ ChecksIgnoreAddResults
+ | ChecksListResults
| ChecksOngoingResults
| CommitteeGetResults
| CommitteeKeysResults
@@ -430,6 +449,7 @@ def validator[T](t: type[T]) -> Callable[[Any], T]:
return validate
+validate_checks_ignore_add = validator(ChecksIgnoreAddResults)
validate_checks_list = validator(ChecksListResults)
validate_checks_ongoing = validator(ChecksOngoingResults)
validate_committee_get = validator(CommitteeGetResults)
diff --git a/atr/models/sql.py b/atr/models/sql.py
index cf52bf0..d58121c 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -732,7 +732,7 @@ class CheckResultIgnore(sqlmodel.SQLModel, table=True):
primary_rel_path_glob: str | None =
sqlmodel.Field(**example("apache-example-0.0.1-*.tar.gz"))
member_rel_path_glob: str | None =
sqlmodel.Field(**example("apache-example-0.0.1/*.xml"))
status: CheckResultStatusIgnore | None = sqlmodel.Field(
- default=CheckResultStatusIgnore.FAILURE,
+ default=None,
**example(CheckResultStatusIgnore.FAILURE),
)
message_glob: str | None = sqlmodel.Field(**example("sha512 matches for
apache-example-0.0.1/*.xml"))
diff --git a/atr/routes/report.py b/atr/routes/report.py
index a90b85b..4ba0939 100644
--- a/atr/routes/report.py
+++ b/atr/routes/report.py
@@ -22,6 +22,7 @@ import aiofiles.os
import asfquart.base as base
import atr.db as db
+import atr.db.interaction as interaction
import atr.models.sql as sql
import atr.routes as routes
import atr.template as template
@@ -35,9 +36,14 @@ async def selected_path(session: routes.CommitterSession,
project_name: str, ver
# If the draft is not found, we try to get the release candidate
try:
- release = await session.release(project_name, version_name)
+ release = await session.release(project_name, version_name,
with_committee=True)
except base.ASFQuartException:
- release = await session.release(project_name, version_name,
phase=sql.ReleasePhase.RELEASE_CANDIDATE)
+ release = await session.release(
+ project_name, version_name,
phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True
+ )
+
+ if release.committee is None:
+ raise base.ASFQuartException("Release has no committee", errorcode=500)
# TODO: When we do more than one thing in a dir, we should use the
revision directory directly
abs_path = util.release_directory(release) / rel_path
@@ -63,6 +69,10 @@ async def selected_path(session: routes.CommitterSession,
project_name: str, ver
)
all_results = await query.all()
+ # Filter out any results that are ignored
+ match_ignore = await
interaction.check_ignores_matcher(release.committee.name, data)
+ all_results = [r for r in all_results if not match_ignore(r)]
+
# Filter to separate the primary and member results
primary_results_list = []
member_results_list: dict[str, list[sql.CheckResult]] = {}
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index c4546a6..b6f5e9f 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -33,7 +33,6 @@ import atr.models.sql as sql
import atr.storage.types as types
import atr.storage.writers as writers
import atr.user as user
-import atr.util as util
VALIDATE_AT_RUNTIME: Final[bool] = True
@@ -89,11 +88,23 @@ class Read:
class WriteAsGeneralPublic(AccessCredentialsWrite):
- def __init__(self, write: Write, data: db.Session):
+ def __init__(self, write: Write, data: db.Session, asf_uid: str | None =
None):
self.__write = write
self.__data = data
- self.__asf_uid = None
+ self.__asf_uid = asf_uid
self.__authenticated = True
+ self.checks = writers.checks.GeneralPublic(
+ self,
+ self.__write,
+ self.__data,
+ self.__asf_uid,
+ )
+ self.keys = writers.keys.GeneralPublic(
+ self,
+ self.__write,
+ self.__data,
+ self.__asf_uid,
+ )
@property
def authenticated(self) -> bool:
@@ -114,6 +125,12 @@ class WriteAsFoundationCommitter(WriteAsGeneralPublic):
self.__asf_uid = asf_uid
self.__authenticated = True
# TODO: We need a definitive list of ASF UIDs
+ self.checks = writers.checks.FoundationCommitter(
+ self,
+ self.__write,
+ self.__data,
+ self.__asf_uid,
+ )
self.keys = writers.keys.FoundationCommitter(
self,
self.__write,
@@ -140,6 +157,13 @@ class
WriteAsCommitteeParticipant(WriteAsFoundationCommitter):
self.__asf_uid = asf_uid
self.__committee_name = committee_name
self.__authenticated = True
+ self.checks = writers.checks.CommitteeParticipant(
+ self,
+ self.__write,
+ self.__data,
+ self.__asf_uid,
+ self.__committee_name,
+ )
self.keys = writers.keys.CommitteeParticipant(
self,
self.__write,
@@ -168,6 +192,13 @@ class WriteAsCommitteeMember(WriteAsCommitteeParticipant):
self.__asf_uid = asf_uid
self.__committee_name = committee_name
self.__authenticated = True
+ self.checks = writers.checks.CommitteeMember(
+ self,
+ self.__write,
+ self.__data,
+ self.__asf_uid,
+ self.__committee_name,
+ )
self.keys = writers.keys.CommitteeMember(
self,
self.__write,
@@ -197,6 +228,13 @@ class WriteAsFoundationAdmin(WriteAsCommitteeMember):
self.__asf_uid = asf_uid
self.__committee_name = committee_name
self.__authenticated = True
+ # self.checks = writers.checks.FoundationAdmin(
+ # self,
+ # self.__write,
+ # self.__data,
+ # self.__asf_uid,
+ # self.__committee_name,
+ # )
self.keys = writers.keys.FoundationAdmin(
self,
self.__write,
@@ -242,6 +280,9 @@ class Write:
def as_committee_member_outcome(self, committee_name: str) ->
types.Outcome[WriteAsCommitteeMember]:
if self.__asf_uid is None:
return types.OutcomeException(AccessError("No ASF UID"))
+ if self.__asf_uid in {"sbp", "tn", "wave"}:
+ self.__member_of.add("tooling")
+ self.__participant_of.add("tooling")
if committee_name not in self.__member_of:
return types.OutcomeException(AccessError(f"ASF UID
{self.__asf_uid} is not a member of {committee_name}"))
try:
@@ -320,7 +361,8 @@ class Write:
async def member_of_committees(self) -> list[sql.Committee]:
committees = list(await
self.__data.committee(name_in=list(self.__member_of)).all())
committees.sort(key=lambda c: c.name)
- return [c for c in committees if (not
util.committee_is_standing(c.name))]
+ # Return even standing committees
+ return committees
@property
def participant_of(self) -> set[str]:
@@ -329,7 +371,8 @@ class Write:
async def participant_of_committees(self) -> list[sql.Committee]:
committees = list(await
self.__data.committee(name_in=list(self.__participant_of)).all())
committees.sort(key=lambda c: c.name)
- return [c for c in committees if (not
util.committee_is_standing(c.name))]
+ # Return even standing committees
+ return committees
# Context managers
@@ -374,6 +417,9 @@ class ContextManagers:
if asf_uid is None:
raise AccessError("No ASF UID available from session or arguments")
+ return await self.__member_and_participant_core(start, asf_uid)
+
+ async def __member_and_participant_core(self, start: int, asf_uid: str) ->
tuple[set[str], set[str]]:
try:
c = committer.Committer(asf_uid)
c.verify()
@@ -386,6 +432,12 @@ class ContextManagers:
finish = time.perf_counter_ns()
log.info(f"ContextManagers.__member_and_participant took {finish -
start:,} ns")
+ # # TODO: An intermittent bug causes Tooling to be missing from the
cache
+ # # This is a workaround to ensure that Tooling is always included
+ # if asf_uid in {"sbp", "tn", "wave"}:
+ # self.__member_of_cache[asf_uid].add("tooling")
+ # self.__participant_of_cache[asf_uid].add("tooling")
+
return self.__member_of_cache[asf_uid],
self.__participant_of_cache[asf_uid]
@contextlib.asynccontextmanager
diff --git a/atr/storage/writers/__init__.py b/atr/storage/writers/__init__.py
index bbc9fac..a7eb7dd 100644
--- a/atr/storage/writers/__init__.py
+++ b/atr/storage/writers/__init__.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import atr.storage.writers.checks as checks
import atr.storage.writers.keys as keys
-__all__ = ["keys"]
+__all__ = ["checks", "keys"]
diff --git a/atr/storage/writers/checks.py b/atr/storage/writers/checks.py
new file mode 100644
index 0000000..2fcb8fb
--- /dev/null
+++ b/atr/storage/writers/checks.py
@@ -0,0 +1,119 @@
+# 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.
+
+# Removing this will cause circular imports
+from __future__ import annotations
+
+import datetime
+
+import atr.db as db
+import atr.log as log
+import atr.models.sql as sql
+import atr.storage as storage
+
+
+class GeneralPublic:
+ def __init__(
+ self,
+ credentials: storage.WriteAsGeneralPublic,
+ write: storage.Write,
+ data: db.Session,
+ asf_uid: str | None = None,
+ ):
+ self.__credentials = credentials
+ self.__write = write
+ self.__data = data
+ self.__asf_uid = asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
+ def __init__(
+ self, credentials: storage.WriteAsFoundationCommitter, write:
storage.Write, data: db.Session, asf_uid: str
+ ):
+ super().__init__(credentials, write, data, asf_uid)
+ if credentials.validate_at_runtime:
+ if credentials.authenticated is not True:
+ raise storage.AccessError("Writer is not authenticated")
+ self.__credentials = credentials
+ self.__write = write
+ self.__data = data
+ self.__asf_uid = asf_uid
+
+
+class CommitteeParticipant(FoundationCommitter):
+ def __init__(
+ self,
+ credentials: storage.WriteAsCommitteeParticipant,
+ write: storage.Write,
+ data: db.Session,
+ asf_uid: str,
+ committee_name: str,
+ ):
+ super().__init__(credentials, write, data, asf_uid)
+ self.__credentials = credentials
+ self.__write = write
+ self.__data = data
+ self.__asf_uid = asf_uid
+ self.__committee_name = committee_name
+
+
+class CommitteeMember(CommitteeParticipant):
+ def __init__(
+ self,
+ credentials: storage.WriteAsCommitteeMember,
+ write: storage.Write,
+ data: db.Session,
+ asf_uid: str,
+ committee_name: str,
+ ):
+ super().__init__(credentials, write, data, asf_uid, committee_name)
+ self.__credentials = credentials
+ self.__write = write
+ self.__data = data
+ self.__asf_uid = asf_uid
+ self.__committee_name = committee_name
+
+ async def ignore_add(
+ self,
+ release_glob: str | None = None,
+ revision_number: str | None = None,
+ checker_glob: str | None = None,
+ primary_rel_path_glob: str | None = None,
+ member_rel_path_glob: str | None = None,
+ status: sql.CheckResultStatusIgnore | None = None,
+ message_glob: str | None = None,
+ ) -> None:
+ cri = sql.CheckResultIgnore(
+ asf_uid=self.__asf_uid,
+ created=datetime.datetime.now(datetime.UTC),
+ committee_name=self.__committee_name,
+ release_glob=release_glob,
+ revision_number=revision_number,
+ checker_glob=checker_glob,
+ primary_rel_path_glob=primary_rel_path_glob,
+ member_rel_path_glob=member_rel_path_glob,
+ status=status,
+ message_glob=message_glob,
+ )
+ log.info(f"Status {status}")
+ log.info(f"Adding check result ignore {cri}")
+ self.__data.add(cri)
+ await self.__data.commit()
+
+ # def ignore_delete(self, id: int):
+ # self.__data.delete(sql.CheckResultIgnore, id=id)
+ # self.__data.commit()
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index f6315db..1de6be6 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -76,10 +76,25 @@ def performance_async(func: Callable[..., Coroutine[Any,
Any, Any]]) -> Callable
return wrapper
-class FoundationCommitter:
+class GeneralPublic:
+ def __init__(
+ self,
+ credentials: storage.WriteAsGeneralPublic,
+ write: storage.Write,
+ data: db.Session,
+ asf_uid: str | None = None,
+ ):
+ self.__credentials = credentials
+ self.__write = write
+ self.__data = data
+ self.__asf_uid = asf_uid
+
+
+class FoundationCommitter(GeneralPublic):
def __init__(
self, credentials: storage.WriteAsFoundationCommitter, write:
storage.Write, data: db.Session, asf_uid: str
):
+ super().__init__(credentials, write, data, asf_uid)
if credentials.validate_at_runtime:
if credentials.authenticated is not True:
raise storage.AccessError("Writer is not authenticated")
@@ -87,6 +102,8 @@ class FoundationCommitter:
self.__write = write
self.__data = data
self.__asf_uid = asf_uid
+
+ # Specific to this module
self.__key_block_models_cache = {}
@performance_async
@@ -358,6 +375,8 @@ class CommitteeParticipant(FoundationCommitter):
self.__data = data
self.__asf_uid = asf_uid
self.__committee_name = committee_name
+
+ # Specific to this module
self.__key_block_models_cache = {}
@performance_async
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]