This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new ece6e9d4 Fix more function ordering by improving the order fixing
script
ece6e9d4 is described below
commit ece6e9d425c625588e4bc009d4ec33fd0510f6b2
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Feb 23 20:33:45 2026 +0000
Fix more function ordering by improving the order fixing script
---
atr/admin/__init__.py | 10 ++--
atr/archives.py | 74 +++++++++++------------
atr/attestable.py | 92 ++++++++++++++--------------
atr/datasources/apache.py | 22 +++----
atr/db/__init__.py | 19 ------
atr/db/interaction.py | 42 ++++++-------
atr/hashes.py | 16 ++---
atr/log.py | 40 +++----------
atr/models/tabulate.py | 58 ++++++++----------
atr/post/draft.py | 54 ++++++++---------
atr/principal.py | 28 ++++-----
atr/sbom/osv.py | 36 +++++------
atr/sbom/tool.py | 42 ++++++-------
atr/sbom/utilities.py | 54 ++++++++---------
atr/storage/__init__.py | 67 ++++++++++-----------
atr/storage/writers/checks.py | 14 ++---
atr/svn/__init__.py | 80 ++++++++++++-------------
atr/tasks/__init__.py | 118 ++++++++++++++++++------------------
atr/tasks/checks/__init__.py | 12 ++--
atr/tasks/gha.py | 135 +++++++++++++++++-------------------------
atr/tasks/sbom.py | 36 +++++------
atr/template.py | 6 +-
atr/validate.py | 74 +++++++++++------------
scripts/fix_order.py | 35 ++++++-----
scripts/fix_order.sh | 3 +-
25 files changed, 547 insertions(+), 620 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 21f95172..27dd52d2 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -577,11 +577,6 @@ async def logs(session: web.Committer) ->
web.QuartResponse:
return web.TextResponse("\n".join(recent_logs))
[email protected]("/raise-error")
-async def raise_error(session: web.Committer) -> web.QuartResponse:
- raise RuntimeError("Admin test route deliberately raised an unhandled
error")
-
-
@admin.get("/ongoing-tasks/<project_name>/<version_name>/<revision>")
async def ongoing_tasks_get(
session: web.Committer, project_name: str, version_name: str, revision: str
@@ -706,6 +701,11 @@ async def projects_update_post(session: web.Committer) ->
str | web.WerkzeugResp
}, 200
[email protected]("/raise-error")
+async def raise_error(session: web.Committer) -> web.QuartResponse:
+ raise RuntimeError("Admin test route deliberately raised an unhandled
error")
+
+
@admin.get("/revoke-user-tokens")
async def revoke_user_tokens_get(session: web.Committer) -> str:
"""Revoke all Personal Access Tokens for a specified user."""
diff --git a/atr/archives.py b/atr/archives.py
index 0ed69824..6b71ae34 100644
--- a/atr/archives.py
+++ b/atr/archives.py
@@ -91,6 +91,43 @@ def total_size(tgz_path: str, chunk_size: int = 4096) -> int:
return total_size
+def _safe_path(base_dir: str, *paths: str) -> str | None:
+ """Return an absolute path within the base_dir built from the given paths,
or None if it escapes."""
+ target = os.path.abspath(os.path.join(base_dir, *paths))
+ abs_base = os.path.abspath(base_dir)
+ if (target == abs_base) or target.startswith(abs_base + os.sep):
+ return target
+ return None
+
+
+def _size_tar(archive: tarzip.Archive, chunk_size: int) -> int:
+ total_size = 0
+ for member in archive:
+ if not isinstance(member, tarzip.TarMember):
+ continue
+ total_size += member.size
+ if member.isfile():
+ fileobj = archive.extractfile(member)
+ if fileobj is not None:
+ while fileobj.read(chunk_size):
+ pass
+ return total_size
+
+
+def _size_zip(archive: tarzip.Archive, chunk_size: int) -> int:
+ total_size = 0
+ for member in archive:
+ if not isinstance(member, tarzip.ZipMember):
+ continue
+ total_size += member.size
+ if member.isfile():
+ fileobj = archive.extractfile(member)
+ if fileobj is not None:
+ while fileobj.read(chunk_size):
+ pass
+ return total_size
+
+
def _tar_archive_extract_member( # noqa: C901
archive: tarzip.Archive,
member: tarzip.TarMember,
@@ -242,43 +279,6 @@ def _tar_archive_extract_safe_process_symlink(member:
tarzip.TarMember, extract_
log.warning(f"Failed to create symlink {target_path} -> {link_target}:
{e}")
-def _safe_path(base_dir: str, *paths: str) -> str | None:
- """Return an absolute path within the base_dir built from the given paths,
or None if it escapes."""
- target = os.path.abspath(os.path.join(base_dir, *paths))
- abs_base = os.path.abspath(base_dir)
- if (target == abs_base) or target.startswith(abs_base + os.sep):
- return target
- return None
-
-
-def _size_tar(archive: tarzip.Archive, chunk_size: int) -> int:
- total_size = 0
- for member in archive:
- if not isinstance(member, tarzip.TarMember):
- continue
- total_size += member.size
- if member.isfile():
- fileobj = archive.extractfile(member)
- if fileobj is not None:
- while fileobj.read(chunk_size):
- pass
- return total_size
-
-
-def _size_zip(archive: tarzip.Archive, chunk_size: int) -> int:
- total_size = 0
- for member in archive:
- if not isinstance(member, tarzip.ZipMember):
- continue
- total_size += member.size
- if member.isfile():
- fileobj = archive.extractfile(member)
- if fileobj is not None:
- while fileobj.read(chunk_size):
- pass
- return total_size
-
-
def _zip_archive_extract_member(
archive: tarzip.Archive,
member: tarzip.ZipMember,
diff --git a/atr/attestable.py b/atr/attestable.py
index 9f80690d..91a65470 100644
--- a/atr/attestable.py
+++ b/atr/attestable.py
@@ -33,6 +33,10 @@ if TYPE_CHECKING:
import pathlib
+def attestable_checks_path(project_name: str, version_name: str,
revision_number: str) -> pathlib.Path:
+ return util.get_attestable_dir() / project_name / version_name /
f"{revision_number}.checks.json"
+
+
def attestable_path(project_name: str, version_name: str, revision_number:
str) -> pathlib.Path:
return util.get_attestable_dir() / project_name / version_name /
f"{revision_number}.json"
@@ -45,10 +49,6 @@ def github_tp_payload_path(project_name: str, version_name:
str, revision_number
return util.get_attestable_dir() / project_name / version_name /
f"{revision_number}.github-tp.json"
-def attestable_checks_path(project_name: str, version_name: str,
revision_number: str) -> pathlib.Path:
- return util.get_attestable_dir() / project_name / version_name /
f"{revision_number}.checks.json"
-
-
async def github_tp_payload_write(
project_name: str, version_name: str, revision_number: str,
github_payload: dict[str, Any]
) -> None:
@@ -73,26 +73,6 @@ async def load(
return None
-async def load_paths(
- project_name: str,
- version_name: str,
- revision_number: str,
-) -> dict[str, str] | None:
- file_path = attestable_paths_path(project_name, version_name,
revision_number)
- if await aiofiles.os.path.isfile(file_path):
- try:
- async with aiofiles.open(file_path, encoding="utf-8") as f:
- data = json.loads(await f.read())
- return models.AttestablePathsV1.model_validate(data).paths
- except (json.JSONDecodeError, pydantic.ValidationError) as e:
- # log.warning(f"Could not parse {file_path}, trying combined file:
{e}")
- log.warning(f"Could not parse {file_path}: {e}")
- # combined = await load(project_name, version_name, revision_number)
- # if combined is not None:
- # return combined.paths
- return None
-
-
async def load_checks(
project_name: str,
version_name: str,
@@ -115,6 +95,26 @@ async def load_checks(
return {}
+async def load_paths(
+ project_name: str,
+ version_name: str,
+ revision_number: str,
+) -> dict[str, str] | None:
+ file_path = attestable_paths_path(project_name, version_name,
revision_number)
+ if await aiofiles.os.path.isfile(file_path):
+ try:
+ async with aiofiles.open(file_path, encoding="utf-8") as f:
+ data = json.loads(await f.read())
+ return models.AttestablePathsV1.model_validate(data).paths
+ except (json.JSONDecodeError, pydantic.ValidationError) as e:
+ # log.warning(f"Could not parse {file_path}, trying combined file:
{e}")
+ log.warning(f"Could not parse {file_path}: {e}")
+ # combined = await load(project_name, version_name, revision_number)
+ # if combined is not None:
+ # return combined.paths
+ return None
+
+
def migrate_to_paths_files() -> int:
attestable_dir = util.get_attestable_dir()
if not attestable_dir.is_dir():
@@ -161,28 +161,6 @@ async def paths_to_hashes_and_sizes(directory:
pathlib.Path) -> tuple[dict[str,
return path_to_hash, path_to_size
-async def write_files_data(
- project_name: str,
- version_name: str,
- revision_number: str,
- release_policy: dict[str, Any] | None,
- uploader_uid: str,
- previous: models.AttestableV1 | None,
- path_to_hash: dict[str, str],
- path_to_size: dict[str, int],
-) -> None:
- result = _generate_files_data(path_to_hash, path_to_size, revision_number,
release_policy, uploader_uid, previous)
- file_path = attestable_path(project_name, version_name, revision_number)
- await util.atomic_write_file(file_path, result.model_dump_json(indent=2))
- paths_result = models.AttestablePathsV1(paths=result.paths)
- paths_file_path = attestable_paths_path(project_name, version_name,
revision_number)
- await util.atomic_write_file(paths_file_path,
paths_result.model_dump_json(indent=2))
- checks_file_path = attestable_checks_path(project_name, version_name,
revision_number)
- if not checks_file_path.exists():
- async with aiofiles.open(checks_file_path, "w", encoding="utf-8") as f:
- await
f.write(models.AttestableChecksV2().model_dump_json(indent=2))
-
-
async def write_checks_data(
project_name: str,
version_name: str,
@@ -207,6 +185,28 @@ async def write_checks_data(
await util.atomic_modify_file(attestable_checks_path(project_name,
version_name, revision_number), modify)
+async def write_files_data(
+ project_name: str,
+ version_name: str,
+ revision_number: str,
+ release_policy: dict[str, Any] | None,
+ uploader_uid: str,
+ previous: models.AttestableV1 | None,
+ path_to_hash: dict[str, str],
+ path_to_size: dict[str, int],
+) -> None:
+ result = _generate_files_data(path_to_hash, path_to_size, revision_number,
release_policy, uploader_uid, previous)
+ file_path = attestable_path(project_name, version_name, revision_number)
+ await util.atomic_write_file(file_path, result.model_dump_json(indent=2))
+ paths_result = models.AttestablePathsV1(paths=result.paths)
+ paths_file_path = attestable_paths_path(project_name, version_name,
revision_number)
+ await util.atomic_write_file(paths_file_path,
paths_result.model_dump_json(indent=2))
+ checks_file_path = attestable_checks_path(project_name, version_name,
revision_number)
+ if not checks_file_path.exists():
+ async with aiofiles.open(checks_file_path, "w", encoding="utf-8") as f:
+ await
f.write(models.AttestableChecksV2().model_dump_json(indent=2))
+
+
def _compute_hashes_with_attribution(
current_hash_to_paths: dict[str, set[str]],
path_to_size: dict[str, int],
diff --git a/atr/datasources/apache.py b/atr/datasources/apache.py
index f1dfcd37..0db6c0e8 100644
--- a/atr/datasources/apache.py
+++ b/atr/datasources/apache.py
@@ -321,17 +321,6 @@ async def update_metadata() -> tuple[int, int]:
return added_count, updated_count
-def _project_status(pmc: sql.Committee, project_name: str, project_status:
ProjectStatus) -> sql.ProjectStatus:
- if pmc.name == "attic":
- # This must come first, because attic is also a standing committee
- return sql.ProjectStatus.RETIRED
- elif ("_dormant_" in project_name) or
project_status.name.endswith("(Dormant)"):
- return sql.ProjectStatus.DORMANT
- elif util.committee_is_standing(pmc.name):
- return sql.ProjectStatus.STANDING
- return sql.ProjectStatus.ACTIVE
-
-
async def _process_undiscovered(data: db.Session) -> tuple[int, int]:
added_count = 0
updated_count = 0
@@ -358,6 +347,17 @@ async def _process_undiscovered(data: db.Session) ->
tuple[int, int]:
return added_count, updated_count
+def _project_status(pmc: sql.Committee, project_name: str, project_status:
ProjectStatus) -> sql.ProjectStatus:
+ if pmc.name == "attic":
+ # This must come first, because attic is also a standing committee
+ return sql.ProjectStatus.RETIRED
+ elif ("_dormant_" in project_name) or
project_status.name.endswith("(Dormant)"):
+ return sql.ProjectStatus.DORMANT
+ elif util.committee_is_standing(pmc.name):
+ return sql.ProjectStatus.STANDING
+ return sql.ProjectStatus.ACTIVE
+
+
async def _update_committees(
data: db.Session, ldap_projects: LDAPProjectsData, committees_by_name:
Mapping[str, Committee]
) -> tuple[int, int]:
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 76df7d4e..97b1f967 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -951,25 +951,6 @@ def log_queries() -> Iterator[None]:
global_log_query = original_global_log_query_state
-# async def recent_tasks(data: Session, release_name: str, file_path: str,
modified: int) -> dict[str, models.Task]:
-# """Get the most recent task for each task type for a specific file."""
-# tasks = await data.task(
-# release_name=release_name,
-# path=str(file_path),
-# modified=modified,
-# ).all()
-#
-# # Group by task_type and keep the most recent one
-# # We use the highest id to determine the most recent task
-# recent_tasks: dict[str, models.Task] = {}
-# for task in tasks:
-# # If we haven't seen this task type before or if this task is newer
-# if (task.task_type.value not in recent_tasks) or (task.id >
recent_tasks[task.task_type.value].id):
-# recent_tasks[task.task_type.value] = task
-#
-# return recent_tasks
-
-
def select_in_load(*entities: Any) -> orm.strategy_options._AbstractLoad:
"""Eagerly load the given entities from the query."""
validated_entities = []
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index bb3ff3ac..6e7e17a5 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -192,6 +192,27 @@ async def checks_for(
return list(check_results)
+async def count_checks_for_revision_by_status(
+ status: sql.CheckResultStatus, release: sql.Release, revision_number: str,
caller_data: db.Session | None = None
+):
+ file_path_checks = await attestable.load_checks(release.project_name,
release.version, revision_number)
+ check_hashes = [h for inner in file_path_checks.values() for h in
inner.values()]
+ if len(check_hashes) == 0:
+ return 0
+ async with db.ensure_session(caller_data) as data:
+ via = sql.validate_instrumented_attribute
+ query = (
+ sqlmodel.select(sqlalchemy.func.count())
+ .select_from(sql.CheckResult)
+ .where(
+ via(sql.CheckResult.inputs_hash).in_(check_hashes),
+ sql.CheckResult.status == status,
+ )
+ )
+ result = await data.execute(query)
+ return result.scalar_one()
+
+
@contextlib.asynccontextmanager
async def ephemeral_gpg_home() -> AsyncGenerator[str]:
"""Create a temporary directory for an isolated GPG home, and clean it up
on exit."""
@@ -550,27 +571,6 @@ async def wait_for_task(
return False
-async def count_checks_for_revision_by_status(
- status: sql.CheckResultStatus, release: sql.Release, revision_number: str,
caller_data: db.Session | None = None
-):
- file_path_checks = await attestable.load_checks(release.project_name,
release.version, revision_number)
- check_hashes = [h for inner in file_path_checks.values() for h in
inner.values()]
- if len(check_hashes) == 0:
- return 0
- async with db.ensure_session(caller_data) as data:
- via = sql.validate_instrumented_attribute
- query = (
- sqlmodel.select(sqlalchemy.func.count())
- .select_from(sql.CheckResult)
- .where(
- via(sql.CheckResult.inputs_hash).in_(check_hashes),
- sql.CheckResult.status == status,
- )
- )
- result = await data.execute(query)
- return result.scalar_one()
-
-
async def _trusted_project(repository: str, workflow_ref: str, phase:
TrustedProjectPhase) -> sql.Project:
# Debugging
log.info(f"GitHub OIDC JWT payload: {repository} {workflow_ref}")
diff --git a/atr/hashes.py b/atr/hashes.py
index 2970e086..de29a760 100644
--- a/atr/hashes.py
+++ b/atr/hashes.py
@@ -25,6 +25,14 @@ import blake3
_HASH_CHUNK_SIZE: Final[int] = 4 * 1024 * 1024
+def compute_dict_hash(to_hash: dict[Any, Any]) -> str:
+ hasher = blake3.blake3()
+ for k in sorted(to_hash.keys()):
+ hasher.update(str(k).encode("utf-8"))
+ hasher.update(str(to_hash[k]).encode("utf-8"))
+ return f"blake3:{hasher.hexdigest()}"
+
+
async def compute_file_hash(path: str | pathlib.Path) -> str:
path = pathlib.Path(path)
hasher = blake3.blake3()
@@ -32,11 +40,3 @@ async def compute_file_hash(path: str | pathlib.Path) -> str:
while chunk := await f.read(_HASH_CHUNK_SIZE):
hasher.update(chunk)
return f"blake3:{hasher.hexdigest()}"
-
-
-def compute_dict_hash(to_hash: dict[Any, Any]) -> str:
- hasher = blake3.blake3()
- for k in sorted(to_hash.keys()):
- hasher.update(str(k).encode("utf-8"))
- hasher.update(str(to_hash[k]).encode("utf-8"))
- return f"blake3:{hasher.hexdigest()}"
diff --git a/atr/log.py b/atr/log.py
index eb81d986..480f5159 100644
--- a/atr/log.py
+++ b/atr/log.py
@@ -94,6 +94,15 @@ def clear_context():
structlog.contextvars.clear_contextvars()
+def create_debug_handler() -> logging.Handler:
+ global _global_recent_logs
+ _global_recent_logs = collections.deque(maxlen=100)
+ handler = BufferingHandler()
+ handler.setFormatter(logging.Formatter("%(asctime)s %(name)s
%(levelname)s: %(message)s"))
+ handler.setLevel(logging.DEBUG)
+ return handler
+
+
def critical(msg: str, **kwargs) -> None:
_event(logging.CRITICAL, msg, **kwargs)
@@ -126,15 +135,6 @@ def info(msg: str, **kwargs) -> None:
_event(logging.INFO, msg, **kwargs)
-def create_debug_handler() -> logging.Handler:
- global _global_recent_logs
- _global_recent_logs = collections.deque(maxlen=100)
- handler = BufferingHandler()
- handler.setFormatter(logging.Formatter("%(asctime)s %(name)s
%(levelname)s: %(message)s"))
- handler.setLevel(logging.DEBUG)
- return handler
-
-
def interface_name(depth: int = 1) -> str:
return caller_name(depth=depth)
@@ -158,28 +158,6 @@ def python_repr(object_name: str) -> str:
return f"<{object_name}>"
-# def secret(msg: str, data: bytes) -> None:
-# import base64
-
-# import nacl.encoding as encoding
-# import nacl.public as public
-
-# import atr.config as config
-
-# conf = config.get()
-# public_key_b64 = conf.LOG_PUBLIC_KEY
-# if public_key_b64 is None:
-# raise ValueError("LOG_PUBLIC_KEY is not set")
-
-# recipient_pk = public.PublicKey(
-# public_key_b64.encode("ascii"),
-# encoder=encoding.Base64Encoder,
-# )
-# ciphertext = public.SealedBox(recipient_pk).encrypt(data)
-# encoded_ciphertext = base64.b64encode(ciphertext).decode("ascii")
-# _event(logging.INFO, f"{msg} {encoded_ciphertext}")
-
-
def warning(msg: str, **kwargs) -> None:
_event(logging.WARNING, msg, **kwargs)
diff --git a/atr/models/tabulate.py b/atr/models/tabulate.py
index fdd0ff17..da6fac30 100644
--- a/atr/models/tabulate.py
+++ b/atr/models/tabulate.py
@@ -16,7 +16,6 @@
# under the License.
import enum
-from typing import Any, Literal
import pydantic
@@ -37,19 +36,15 @@ class VoteStatus(enum.Enum):
UNKNOWN = "Unknown"
-def example(value: Any) -> dict[Literal["json_schema_extra"], dict[str, Any]]:
- return {"json_schema_extra": {"example": value}}
-
-
class VoteEmail(schema.Strict):
- asf_uid_or_email: str = schema.Field(..., **example("user"))
- from_email: str = schema.Field(..., **example("[email protected]"))
- status: VoteStatus = schema.Field(..., **example(VoteStatus.BINDING))
- asf_eid: str = schema.Field(...,
**example("[email protected]"))
- iso_datetime: str = schema.Field(..., **example("2025-05-01T12:00:00Z"))
- vote: Vote = schema.Field(..., **example(Vote.YES))
- quotation: str = schema.Field(..., **example("+1 (Binding)"))
- updated: bool = schema.Field(..., **example(True))
+ asf_uid_or_email: str = schema.example("user")
+ from_email: str = schema.example("[email protected]")
+ status: VoteStatus = schema.example(VoteStatus.BINDING)
+ asf_eid: str =
schema.example("[email protected]")
+ iso_datetime: str = schema.example("2025-05-01T12:00:00Z")
+ vote: Vote = schema.example(Vote.YES)
+ quotation: str = schema.example("+1 (Binding)")
+ updated: bool = schema.example(True)
@pydantic.field_validator("status", mode="before")
@classmethod
@@ -63,24 +58,21 @@ class VoteEmail(schema.Strict):
class VoteDetails(schema.Strict):
- start_unixtime: int | None = schema.Field(..., **example(1714435200))
- votes: dict[str, VoteEmail] = schema.Field(
- ...,
- **example(
- {
- "user": VoteEmail(
- asf_uid_or_email="user",
- from_email="[email protected]",
- status=VoteStatus.BINDING,
- asf_eid="[email protected]",
- iso_datetime="2025-05-01T12:00:00Z",
- vote=Vote.YES,
- quotation="+1 (Binding)",
- updated=True,
- )
- }
- ),
+ start_unixtime: int | None = schema.example(1714435200)
+ votes: dict[str, VoteEmail] = schema.example(
+ {
+ "user": VoteEmail(
+ asf_uid_or_email="user",
+ from_email="[email protected]",
+ status=VoteStatus.BINDING,
+ asf_eid="[email protected]",
+ iso_datetime="2025-05-01T12:00:00Z",
+ vote=Vote.YES,
+ quotation="+1 (Binding)",
+ updated=True,
+ )
+ }
)
- summary: dict[str, int] = schema.Field(..., **example({"user": 1}))
- passed: bool = schema.Field(..., **example(True))
- outcome: str = schema.Field(..., **example("The vote passed."))
+ summary: dict[str, int] = schema.example({"user": 1})
+ passed: bool = schema.example(True)
+ outcome: str = schema.example("The vote passed.")
diff --git a/atr/post/draft.py b/atr/post/draft.py
index 9b57b708..c35a3234 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -38,6 +38,33 @@ if TYPE_CHECKING:
import pathlib
[email protected]("/draft/reset/<project_name>/<version_name>")
[email protected]()
+async def cache_reset(session: web.Committer, project_name: str, version_name:
str) -> web.WerkzeugResponse:
+ """Start a new draft revision and switch this release to global caching"""
+ await session.check_access(project_name)
+ if not session.is_admin:
+ raise base.ASFQuartException("Admin access required", errorcode=403)
+
+ description = "Empty revision to restart all checks without cache for the
whole release candidate draft"
+ async with storage.write(session) as write:
+ wacp = await write.as_project_committee_participant(project_name)
+ await wacp.revision.create_revision(
+ project_name,
+ version_name,
+ session.uid,
+ description=description,
+ reset_to_global_cache=True,
+ )
+
+ return await session.redirect(
+ get.compose.selected,
+ project_name=project_name,
+ version_name=version_name,
+ success="Release set back to global caching",
+ )
+
+
@post.committer("/compose/<project_name>/<version_name>")
@post.empty()
async def delete(session: web.Committer, project_name: str, version_name: str)
-> web.WerkzeugResponse:
@@ -145,33 +172,6 @@ async def recheck(session: web.Committer, project_name:
str, version_name: str)
)
[email protected]("/draft/reset/<project_name>/<version_name>")
[email protected]()
-async def cache_reset(session: web.Committer, project_name: str, version_name:
str) -> web.WerkzeugResponse:
- """Start a new draft revision and switch this release to global caching"""
- await session.check_access(project_name)
- if not session.is_admin:
- raise base.ASFQuartException("Admin access required", errorcode=403)
-
- description = "Empty revision to restart all checks without cache for the
whole release candidate draft"
- async with storage.write(session) as write:
- wacp = await write.as_project_committee_participant(project_name)
- await wacp.revision.create_revision(
- project_name,
- version_name,
- session.uid,
- description=description,
- reset_to_global_cache=True,
- )
-
- return await session.redirect(
- get.compose.selected,
- project_name=project_name,
- version_name=version_name,
- success="Release set back to global caching",
- )
-
-
@post.committer("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>")
@post.empty()
async def sbomgen(session: web.Committer, project_name: str, version_name:
str, file_path: str) -> web.WerkzeugResponse:
diff --git a/atr/principal.py b/atr/principal.py
index 30b3027a..2e602b33 100644
--- a/atr/principal.py
+++ b/atr/principal.py
@@ -63,20 +63,6 @@ ArgumentNone = ArgumentNoneType()
type UID = web.Committer | str | None | ArgumentNoneType
-def attr_to_list(attr):
- """Converts a list of bytestring attribute values to a unique list of
strings."""
- return list(set([value for value in attr or []]))
-
-
-def get_ldap_bind_dn_and_password() -> tuple[str, str]:
- conf = config.get()
- bind_dn = conf.LDAP_BIND_DN
- bind_password = conf.LDAP_BIND_PASSWORD
- if (not bind_dn) or (not bind_password):
- raise CommitterError("LDAP bind DN or password not set")
- return bind_dn, bind_password
-
-
class Committer:
"""Verifies and loads a committer's credentials via LDAP."""
@@ -418,6 +404,20 @@ class Authorisation(AsyncObject):
return self.__authoriser.member_of(self.__asf_uid)
+def attr_to_list(attr):
+ """Converts a list of bytestring attribute values to a unique list of
strings."""
+ return list(set([value for value in attr or []]))
+
+
+def get_ldap_bind_dn_and_password() -> tuple[str, str]:
+ conf = config.get()
+ bind_dn = conf.LDAP_BIND_DN
+ bind_password = conf.LDAP_BIND_PASSWORD
+ if (not bind_dn) or (not bind_password):
+ raise CommitterError("LDAP bind DN or password not set")
+ return bind_dn, bind_password
+
+
def _augment_test_membership(
committees: frozenset[str],
projects: frozenset[str],
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index 028e7574..227c7816 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -100,14 +100,6 @@ async def scan_bundle(bundle: models.bundle.Bundle) ->
tuple[list[models.osv.Com
return result, ignored
-def vulns_from_bundle(bundle: models.bundle.Bundle) ->
list[models.osv.CdxVulnerabilityDetail]:
- vulns = get_pointer(bundle.doc, "/vulnerabilities")
- if vulns is None:
- return []
- print(vulns)
- return [models.osv.CdxVulnerabilityDetail.model_validate(v) for v in vulns]
-
-
async def vuln_patch(
doc: yyjson.Document,
components: list[models.osv.ComponentVulnerabilities],
@@ -122,16 +114,12 @@ async def vuln_patch(
return patch_ops
-def _assemble_vulnerabilities(doc: yyjson.Document, patch_ops:
models.patch.Patch) -> None:
- if get_pointer(doc, "/vulnerabilities") is not None:
- patch_ops.append(models.patch.RemoveOp(op="remove",
path="/vulnerabilities"))
- patch_ops.append(
- models.patch.AddOp(
- op="add",
- path="/vulnerabilities",
- value=[],
- )
- )
+def vulns_from_bundle(bundle: models.bundle.Bundle) ->
list[models.osv.CdxVulnerabilityDetail]:
+ vulns = get_pointer(bundle.doc, "/vulnerabilities")
+ if vulns is None:
+ return []
+ print(vulns)
+ return [models.osv.CdxVulnerabilityDetail.model_validate(v) for v in vulns]
def _assemble_component_vulnerability(
@@ -165,6 +153,18 @@ def _assemble_component_vulnerability(
)
+def _assemble_vulnerabilities(doc: yyjson.Document, patch_ops:
models.patch.Patch) -> None:
+ if get_pointer(doc, "/vulnerabilities") is not None:
+ patch_ops.append(models.patch.RemoveOp(op="remove",
path="/vulnerabilities"))
+ patch_ops.append(
+ models.patch.AddOp(
+ op="add",
+ path="/vulnerabilities",
+ value=[],
+ )
+ )
+
+
def _component_purl_with_version(component: models.bom.Component) -> str |
None:
if component.purl is None:
return None
diff --git a/atr/sbom/tool.py b/atr/sbom/tool.py
index b7f84ae1..08e98802 100644
--- a/atr/sbom/tool.py
+++ b/atr/sbom/tool.py
@@ -38,6 +38,27 @@ _KNOWN_TOOLS: Final[dict[str, models.tool.Tool]] = {
}
+def outdated_version_core(
+ isotime: str, version: str, version_as_of: Callable[[str], str | None]
+) -> semver.VersionInfo | None:
+ expected_version = version_as_of(isotime)
+ if expected_version is None:
+ return None
+ if version == expected_version:
+ return None
+ expected_version_comparable = version_parse(expected_version)
+ version_comparable = version_parse(version)
+ if (expected_version_comparable is None) or (version_comparable is None):
+ # Couldn't parse the version
+ return None
+ # If the version used is less than the version available
+ if version_comparable < expected_version_comparable:
+ # Then note the version available
+ return expected_version_comparable
+ # Otherwise, the user is using the latest version
+ return None
+
+
def plugin_outdated_version(bom_value: models.bom.Bom) ->
list[models.tool.Outdated] | None:
if bom_value.metadata is None:
return [models.tool.OutdatedMissingMetadata()]
@@ -76,27 +97,6 @@ def plugin_outdated_version(bom_value: models.bom.Bom) ->
list[models.tool.Outda
return errors
-def outdated_version_core(
- isotime: str, version: str, version_as_of: Callable[[str], str | None]
-) -> semver.VersionInfo | None:
- expected_version = version_as_of(isotime)
- if expected_version is None:
- return None
- if version == expected_version:
- return None
- expected_version_comparable = version_parse(expected_version)
- version_comparable = version_parse(version)
- if (expected_version_comparable is None) or (version_comparable is None):
- # Couldn't parse the version
- return None
- # If the version used is less than the version available
- if version_comparable < expected_version_comparable:
- # Then note the version available
- return expected_version_comparable
- # Otherwise, the user is using the latest version
- return None
-
-
def version_parse(version_str: str) -> semver.VersionInfo | None:
try:
return semver.VersionInfo.parse(version_str.lstrip("v"))
diff --git a/atr/sbom/utilities.py b/atr/sbom/utilities.py
index 027ebc0c..5d4e94a7 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -33,18 +33,18 @@ import atr.util as util
from . import constants, models
+if True:
-def get_atr_version():
- try:
- from atr import metadata
+ def get_atr_version():
+ try:
+ from atr import metadata
- return metadata.version
- except ImportError:
- return "cli"
+ return metadata.version
+ except ImportError:
+ return "cli"
_ATR_VERSION = get_atr_version()
-
_SCORING_METHODS_OSV = {"CVSS_V2": "CVSSv2", "CVSS_V3": "CVSSv3", "CVSS_V4":
"CVSSv4"}
_SCORING_METHODS_CDX = {"CVSSv2": "CVSS_V2", "CVSSv3": "CVSS_V3", "CVSSv4":
"CVSS_V4", "other": "Other"}
_CDX_SEVERITIES = ["critical", "high", "medium", "low", "info", "none",
"unknown"]
@@ -89,6 +89,18 @@ async def bundle_to_vuln_patch(
return patch_ops
+def cdx_severity_to_osv(severity: list[dict[str, str | float]]) -> tuple[str |
None, list[dict[str, str]]]:
+ severities = [
+ {
+ "score": str(s.get("score", str(s.get("vector", "")))),
+ "type": _SCORING_METHODS_CDX.get(str(s.get("method", "other"))),
+ }
+ for s in severity
+ ]
+ textual = severity[0].get("severity")
+ return str(textual), severities
+
+
def get_pointer(doc: yyjson.Document, path: str) -> Any | None:
try:
return doc.get_pointer(path)
@@ -109,16 +121,6 @@ def get_props_from_bundle(bundle_value:
models.bundle.Bundle) -> tuple[int, list
return version, [p for p in properties if "asf:atr:" in p.get("name", "")]
-def patch_to_data(patch_ops: models.patch.Patch) -> list[dict[str, Any]]:
- return [op.model_dump(by_alias=True, exclude_none=True) for op in
patch_ops]
-
-
-def path_to_bundle(path: pathlib.Path) -> models.bundle.Bundle:
- text = path.read_text(encoding="utf-8")
- bom = models.bom.Bom.model_validate_json(text)
- return models.bundle.Bundle(doc=yyjson.Document(text), bom=bom, path=path,
text=text)
-
-
def osv_severity_to_cdx(severity: list[dict[str, Any]] | None, textual: str)
-> list[dict[str, str | float]] | None:
if severity is not None:
return [
@@ -132,16 +134,14 @@ def osv_severity_to_cdx(severity: list[dict[str, Any]] |
None, textual: str) ->
return None
-def cdx_severity_to_osv(severity: list[dict[str, str | float]]) -> tuple[str |
None, list[dict[str, str]]]:
- severities = [
- {
- "score": str(s.get("score", str(s.get("vector", "")))),
- "type": _SCORING_METHODS_CDX.get(str(s.get("method", "other"))),
- }
- for s in severity
- ]
- textual = severity[0].get("severity")
- return str(textual), severities
+def patch_to_data(patch_ops: models.patch.Patch) -> list[dict[str, Any]]:
+ return [op.model_dump(by_alias=True, exclude_none=True) for op in
patch_ops]
+
+
+def path_to_bundle(path: pathlib.Path) -> models.bundle.Bundle:
+ text = path.read_text(encoding="utf-8")
+ bom = models.bom.Bom.model_validate_json(text)
+ return models.bundle.Bundle(doc=yyjson.Document(text), bom=bom, path=path,
text=text)
def _extract_cdx_score(type: str, score_str: str) -> dict[str, str | float]:
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 7c758a94..15314b26 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -41,27 +41,6 @@ import atr.user as user
## Access credentials
-# Do not rename this interface
-# It is named to reserve the atr.storage.audit logger name
-def audit(**kwargs: basic.JSON) -> None:
- now =
datetime.datetime.now(datetime.UTC).isoformat(timespec="milliseconds")
- now = now.replace("+00:00", "Z")
- action = log.caller_name(depth=2)
- request_user_id = log.get_context("user_id")
- admin_user_id = log.get_context("admin_id")
- kwargs = {"datetime": now, "action": action, **kwargs}
- if request_user_id:
- kwargs["request_user_id"] = request_user_id
- if admin_user_id and (request_user_id != admin_user_id):
- kwargs["admin_user_id"] = admin_user_id
- msg = json.dumps(kwargs, allow_nan=False)
- # The atr.log logger should give the same name
- # But to be extra sure, we set it manually
- logger = logging.getLogger("atr.storage.audit")
- # TODO: Convert to async
- logger.info(msg)
-
-
class AccessAs:
def append_to_audit_log(self, **kwargs: basic.JSON) -> None:
audit(**kwargs)
@@ -404,7 +383,25 @@ class Write:
return committees
-# Context managers
+# Do not rename this interface
+# It is named to reserve the atr.storage.audit logger name
+def audit(**kwargs: basic.JSON) -> None:
+ now =
datetime.datetime.now(datetime.UTC).isoformat(timespec="milliseconds")
+ now = now.replace("+00:00", "Z")
+ action = log.caller_name(depth=2)
+ request_user_id = log.get_context("user_id")
+ admin_user_id = log.get_context("admin_id")
+ kwargs = {"datetime": now, "action": action, **kwargs}
+ if request_user_id:
+ kwargs["request_user_id"] = request_user_id
+ if admin_user_id and (request_user_id != admin_user_id):
+ kwargs["admin_user_id"] = admin_user_id
+ msg = json.dumps(kwargs, allow_nan=False)
+ # The atr.log logger should give the same name
+ # But to be extra sure, we set it manually
+ logger = logging.getLogger("atr.storage.audit")
+ # TODO: Convert to async
+ logger.info(msg)
@contextlib.asynccontextmanager
@@ -418,6 +415,19 @@ async def read(asf_uid: principal.UID =
principal.ArgumentNone) -> AsyncGenerato
yield Read(authorisation, data)
[email protected]
+async def read_and_write(asf_uid: principal.UID = principal.ArgumentNone) ->
AsyncGenerator[tuple[Read, Write]]:
+ if asf_uid is principal.ArgumentNone:
+ authorisation = await principal.Authorisation()
+ else:
+ authorisation = await principal.Authorisation(asf_uid)
+ async with db.session() as data:
+ # TODO: Replace data with a DatabaseWriter instance
+ r = Read(authorisation, data)
+ w = Write(authorisation, data)
+ yield r, w
+
+
@contextlib.asynccontextmanager
async def read_as_foundation_committer(
asf_uid: principal.UID = principal.ArgumentNone,
@@ -434,19 +444,6 @@ async def read_as_general_public(
yield r.as_general_public()
[email protected]
-async def read_and_write(asf_uid: principal.UID = principal.ArgumentNone) ->
AsyncGenerator[tuple[Read, Write]]:
- if asf_uid is principal.ArgumentNone:
- authorisation = await principal.Authorisation()
- else:
- authorisation = await principal.Authorisation(asf_uid)
- async with db.session() as data:
- # TODO: Replace data with a DatabaseWriter instance
- r = Read(authorisation, data)
- w = Write(authorisation, data)
- yield r, w
-
-
@contextlib.asynccontextmanager
async def write(asf_uid: principal.UID = principal.ArgumentNone) ->
AsyncGenerator[Write]:
if asf_uid is principal.ArgumentNone:
diff --git a/atr/storage/writers/checks.py b/atr/storage/writers/checks.py
index 452559a4..5b2dc0a6 100644
--- a/atr/storage/writers/checks.py
+++ b/atr/storage/writers/checks.py
@@ -28,13 +28,6 @@ import atr.models.validation as validation
import atr.storage as storage
-def _validate_ignore_patterns(*patterns: str | None) -> None:
- for pattern in patterns:
- if pattern is None:
- continue
- validation.validate_ignore_pattern(pattern)
-
-
class GeneralPublic:
def __init__(
self,
@@ -191,3 +184,10 @@ class CommitteeMember(CommitteeParticipant):
raise storage.AccessError(f"Project {project_name} not found")
if project.committee_name != self.__committee_name:
raise storage.AccessError(f"Project {project_name} is not in
committee {self.__committee_name}")
+
+
+def _validate_ignore_patterns(*patterns: str | None) -> None:
+ for pattern in patterns:
+ if pattern is None:
+ continue
+ validation.validate_ignore_pattern(pattern)
diff --git a/atr/svn/__init__.py b/atr/svn/__init__.py
index b887a793..a41a6bc0 100644
--- a/atr/svn/__init__.py
+++ b/atr/svn/__init__.py
@@ -90,46 +90,6 @@ class SvnLog(pydantic_xml.BaseXmlModel, tag="log"):
entries: list[SvnLogEntry] = pydantic_xml.element(tag="logentry")
-async def _run_svnmucc_command(*args: str) -> str:
- return await run_command("svnmucc", *args)
-
-
-async def _run_svn_command(sub_cmd: str, path: str, *args: str) -> str:
- # Do not log this command, as it may contain a password or secret token
- return await run_command("svn", *[sub_cmd, *args, path])
-
-
-async def _run_svn_info(path_or_url: str) -> str:
- log.debug(f"fetching svn info for '{path_or_url}'")
- return await _run_svn_command("info", path_or_url)
-
-
-async def update(path: pathlib.Path) -> str:
- log.debug(f"running svn update for '{path}'")
- return await _run_svn_command("update", str(path), "--parents")
-
-
-async def get_log(path: pathlib.Path) -> SvnLog:
- log.debug(f"running svn log for '{path}'")
- svn_token = config.get().SVN_TOKEN
- if svn_token is None:
- raise ValueError("SVN_TOKEN must be set")
- # TODO: Or omit username entirely?
- log_output = await _run_svn_command("log", str(path), "--xml",
"--username", _ASF_TOOL, "--password", svn_token)
- return SvnLog.from_xml(log_output)
-
-
-async def get_diff(path: pathlib.Path, revision: int) -> str:
- log.debug(f"running svn diff for '{path}': r{revision}")
- svn_token = config.get().SVN_TOKEN
- if svn_token is None:
- raise ValueError("SVN_TOKEN must be set")
- # TODO: Or omit username entirely?
- return await _run_svn_command(
- "diff", str(path), "-c", str(revision), "--username", _ASF_TOOL,
"--password", svn_token
- )
-
-
async def commit(path: pathlib.Path, url: str, username: str, revision: str,
message: str) -> str:
log.debug(f"running svn commit for user '{username}' to '{url}'")
# The username here is the ASF UID of the committer
@@ -154,6 +114,27 @@ async def commit(path: pathlib.Path, url: str, username:
str, revision: str, mes
)
+async def get_diff(path: pathlib.Path, revision: int) -> str:
+ log.debug(f"running svn diff for '{path}': r{revision}")
+ svn_token = config.get().SVN_TOKEN
+ if svn_token is None:
+ raise ValueError("SVN_TOKEN must be set")
+ # TODO: Or omit username entirely?
+ return await _run_svn_command(
+ "diff", str(path), "-c", str(revision), "--username", _ASF_TOOL,
"--password", svn_token
+ )
+
+
+async def get_log(path: pathlib.Path) -> SvnLog:
+ log.debug(f"running svn log for '{path}'")
+ svn_token = config.get().SVN_TOKEN
+ if svn_token is None:
+ raise ValueError("SVN_TOKEN must be set")
+ # TODO: Or omit username entirely?
+ log_output = await _run_svn_command("log", str(path), "--xml",
"--username", _ASF_TOOL, "--password", svn_token)
+ return SvnLog.from_xml(log_output)
+
+
async def run_command(cmd: str, *args: str) -> str:
"""Run a svn command asynchronously.
@@ -177,3 +158,22 @@ async def run_command(cmd: str, *args: str) -> str:
else:
output = stdout.decode().strip()
return output
+
+
+async def update(path: pathlib.Path) -> str:
+ log.debug(f"running svn update for '{path}'")
+ return await _run_svn_command("update", str(path), "--parents")
+
+
+async def _run_svn_command(sub_cmd: str, path: str, *args: str) -> str:
+ # Do not log this command, as it may contain a password or secret token
+ return await run_command("svn", *[sub_cmd, *args, path])
+
+
+async def _run_svn_info(path_or_url: str) -> str:
+ log.debug(f"fetching svn info for '{path_or_url}'")
+ return await _run_svn_command("info", path_or_url)
+
+
+async def _run_svnmucc_command(*args: str) -> str:
+ return await run_command("svnmucc", *args)
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 3acb79df..282db9cf 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -197,50 +197,6 @@ async def draft_checks(
return len(relative_paths)
-async def _draft_file_checks(
- asf_uid: str,
- caller_data: db.Session | None,
- data: db.Session,
- path: pathlib.Path,
- previous_version: sql.Release | None,
- project_name: str,
- release: sql.Release,
- release_version: str,
- revision_number: str,
-):
- path_str = str(path)
- task_function: Callable[[str, sql.Release, str, str, db.Session],
Awaitable[list[sql.Task | None]]] | None = None
- for suffix, func in TASK_FUNCTIONS.items():
- if path.name.endswith(suffix):
- task_function = func
- break
- if task_function:
- for task in await task_function(asf_uid, release, revision_number,
path_str, data):
- if task:
- task.revision_number = revision_number
- await _add_task(data, task)
- # TODO: Should we check .json files for their content?
- # Ideally we would not have to do that
- if path.name.endswith(".cdx.json"):
- cdx_task = await queued(
- asf_uid,
- sql.TaskType.SBOM_TOOL_SCORE,
- release,
- revision_number,
- path_str,
- extra_args={
- "project_name": project_name,
- "version_name": release_version,
- "revision_number": revision_number,
- "previous_release_version": previous_version.version if
previous_version else None,
- "file_path": path_str,
- "asf_uid": asf_uid,
- },
- )
- if cdx_task:
- await _add_task(data, cdx_task)
-
-
async def keys_import_file(
asf_uid: str, project_name: str, version_name: str, revision_number: str,
caller_data: db.Session | None = None
) -> None:
@@ -324,21 +280,6 @@ async def queued(
)
-async def _add_task(data: db.Session, task: sql.Task) -> None:
- try:
- async with data.begin_nested():
- data.add(task)
- await data.flush()
- except sqlalchemy.exc.IntegrityError as e:
- if (
- isinstance(e.orig, sqlite3.IntegrityError)
- and (e.orig.sqlite_errorcode == sqlite3.SQLITE_CONSTRAINT_UNIQUE)
- and ("task.inputs_hash" in str(e.orig))
- ):
- return
- raise
-
-
def resolve(task_type: sql.TaskType) -> Callable[...,
Awaitable[results.Results | None]]: # noqa: C901
match task_type:
case sql.TaskType.COMPARE_SOURCE_TREES:
@@ -608,6 +549,65 @@ async def zip_checks(
return await asyncio.gather(*tasks)
+async def _add_task(data: db.Session, task: sql.Task) -> None:
+ try:
+ async with data.begin_nested():
+ data.add(task)
+ await data.flush()
+ except sqlalchemy.exc.IntegrityError as e:
+ if (
+ isinstance(e.orig, sqlite3.IntegrityError)
+ and (e.orig.sqlite_errorcode == sqlite3.SQLITE_CONSTRAINT_UNIQUE)
+ and ("task.inputs_hash" in str(e.orig))
+ ):
+ return
+ raise
+
+
+async def _draft_file_checks(
+ asf_uid: str,
+ caller_data: db.Session | None,
+ data: db.Session,
+ path: pathlib.Path,
+ previous_version: sql.Release | None,
+ project_name: str,
+ release: sql.Release,
+ release_version: str,
+ revision_number: str,
+):
+ path_str = str(path)
+ task_function: Callable[[str, sql.Release, str, str, db.Session],
Awaitable[list[sql.Task | None]]] | None = None
+ for suffix, func in TASK_FUNCTIONS.items():
+ if path.name.endswith(suffix):
+ task_function = func
+ break
+ if task_function:
+ for task in await task_function(asf_uid, release, revision_number,
path_str, data):
+ if task:
+ task.revision_number = revision_number
+ await _add_task(data, task)
+ # TODO: Should we check .json files for their content?
+ # Ideally we would not have to do that
+ if path.name.endswith(".cdx.json"):
+ cdx_task = await queued(
+ asf_uid,
+ sql.TaskType.SBOM_TOOL_SCORE,
+ release,
+ revision_number,
+ path_str,
+ extra_args={
+ "project_name": project_name,
+ "version_name": release_version,
+ "revision_number": revision_number,
+ "previous_release_version": previous_version.version if
previous_version else None,
+ "file_path": path_str,
+ "asf_uid": asf_uid,
+ },
+ )
+ if cdx_task:
+ await _add_task(data, cdx_task)
+
+
TASK_FUNCTIONS: Final[dict[str, Callable[..., Coroutine[Any, Any,
list[sql.Task | None]]]]] = {
".asc": asc_checks,
".sha256": sha_checks,
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index a0335aa6..4bdcf40b 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -393,8 +393,10 @@ async def _resolve_all_files(release: sql.Release,
rel_path: str | None = None)
return list(sorted(relative_paths_set))
-async def _resolve_is_podling(release: sql.Release, rel_path: str | None =
None) -> bool:
- return (release.committee is not None) and release.committee.is_podling
+async def _resolve_committee_name(release: sql.Release, rel_path: str | None =
None) -> str:
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+ return release.committee.name
async def _resolve_github_tp_sha(release: sql.Release, rel_path: str | None =
None) -> str:
@@ -421,10 +423,8 @@ async def _resolve_github_tp_sha(release: sql.Release,
rel_path: str | None = No
return ""
-async def _resolve_committee_name(release: sql.Release, rel_path: str | None =
None) -> str:
- if release.committee is None:
- raise ValueError("Release has no committee")
- return release.committee.name
+async def _resolve_is_podling(release: sql.Release, rel_path: str | None =
None) -> bool:
+ return (release.committee is not None) and release.committee.is_podling
async def _resolve_unsuffixed_file_hash(release: sql.Release, rel_path: str |
None = None) -> str:
diff --git a/atr/tasks/gha.py b/atr/tasks/gha.py
index 0830dfd8..6be367b3 100644
--- a/atr/tasks/gha.py
+++ b/atr/tasks/gha.py
@@ -66,61 +66,6 @@ class WorkflowStatusCheck(schema.Strict):
asf_uid: str = schema.description("ASF UID of the user triggering the
workflow")
[email protected]_model(DistributionWorkflow)
-async def trigger_workflow(args: DistributionWorkflow, *, task_id: int | None
= None) -> results.Results | None:
- unique_id = f"atr-dist-{args.name}-{uuid.uuid4()}"
- try:
- sql_platform = sql.DistributionPlatform[args.platform]
- except KeyError:
- _fail(f"Invalid platform: {args.platform}")
- workflow = f"distribute-{sql_platform.value.gh_slug}{'-stg' if
args.staging else ''}.yml"
- payload = {
- "ref": "main",
- "inputs": {
- "atr-id": unique_id,
- "asf-uid": args.asf_uid,
- "project": args.project_name,
- "phase": args.phase,
- "version": args.version_name,
- "distribution-owner-namespace": args.namespace,
- "distribution-package": args.package,
- "distribution-version": args.version,
- **args.arguments,
- },
- }
- headers = {"Accept": "application/vnd.github+json", "Authorization":
f"Bearer {config.get().GITHUB_TOKEN}"}
- log.info(
- f"Triggering Github workflow apache/tooling-actions/{workflow} with
args: {
- json.dumps(args.arguments, indent=2)
- }"
- )
- async with util.create_secure_session() as session:
- try:
- async with session.post(
-
f"{_BASE_URL}/apache/tooling-actions/actions/workflows/{workflow}/dispatches",
- headers=headers,
- json=payload,
- ) as response:
- response.raise_for_status()
- except aiohttp.ClientResponseError as e:
- _fail(f"Failed to trigger GitHub workflow: {e.message}
({e.status})")
-
- run, run_id = await _find_triggered_run(session, headers, unique_id)
-
- if run.get("status") in _FAILED_STATUSES:
- _fail(f"Github workflow apache/tooling-actions/{workflow} run
{run_id} failed with error")
- async with storage.write_as_committee_member(args.committee_name,
args.asf_uid) as w:
- try:
- await w.workflowstatus.add_workflow_status(
- workflow, run_id, args.project_name, task_id,
status=run.get("status")
- )
- except storage.AccessError as e:
- _fail(f"Failed to record distribution: {e}")
- return results.DistributionWorkflow(
- kind="distribution_workflow", name=args.name, run_id=run_id,
url=run.get("html_url", "")
- )
-
-
@checks.with_model(WorkflowStatusCheck)
async def status_check(args: WorkflowStatusCheck) ->
DistributionWorkflowStatus:
"""Check remote workflow statuses."""
@@ -193,6 +138,61 @@ async def status_check(args: WorkflowStatusCheck) ->
DistributionWorkflowStatus:
_fail(f"Unexpected error during workflow status update: {e!s}")
[email protected]_model(DistributionWorkflow)
+async def trigger_workflow(args: DistributionWorkflow, *, task_id: int | None
= None) -> results.Results | None:
+ unique_id = f"atr-dist-{args.name}-{uuid.uuid4()}"
+ try:
+ sql_platform = sql.DistributionPlatform[args.platform]
+ except KeyError:
+ _fail(f"Invalid platform: {args.platform}")
+ workflow = f"distribute-{sql_platform.value.gh_slug}{'-stg' if
args.staging else ''}.yml"
+ payload = {
+ "ref": "main",
+ "inputs": {
+ "atr-id": unique_id,
+ "asf-uid": args.asf_uid,
+ "project": args.project_name,
+ "phase": args.phase,
+ "version": args.version_name,
+ "distribution-owner-namespace": args.namespace,
+ "distribution-package": args.package,
+ "distribution-version": args.version,
+ **args.arguments,
+ },
+ }
+ headers = {"Accept": "application/vnd.github+json", "Authorization":
f"Bearer {config.get().GITHUB_TOKEN}"}
+ log.info(
+ f"Triggering Github workflow apache/tooling-actions/{workflow} with
args: {
+ json.dumps(args.arguments, indent=2)
+ }"
+ )
+ async with util.create_secure_session() as session:
+ try:
+ async with session.post(
+
f"{_BASE_URL}/apache/tooling-actions/actions/workflows/{workflow}/dispatches",
+ headers=headers,
+ json=payload,
+ ) as response:
+ response.raise_for_status()
+ except aiohttp.ClientResponseError as e:
+ _fail(f"Failed to trigger GitHub workflow: {e.message}
({e.status})")
+
+ run, run_id = await _find_triggered_run(session, headers, unique_id)
+
+ if run.get("status") in _FAILED_STATUSES:
+ _fail(f"Github workflow apache/tooling-actions/{workflow} run
{run_id} failed with error")
+ async with storage.write_as_committee_member(args.committee_name,
args.asf_uid) as w:
+ try:
+ await w.workflowstatus.add_workflow_status(
+ workflow, run_id, args.project_name, task_id,
status=run.get("status")
+ )
+ except storage.AccessError as e:
+ _fail(f"Failed to record distribution: {e}")
+ return results.DistributionWorkflow(
+ kind="distribution_workflow", name=args.name, run_id=run_id,
url=run.get("html_url", "")
+ )
+
+
def _fail(message: str) -> NoReturn:
log.error(message)
raise RuntimeError(message)
@@ -222,31 +222,6 @@ async def _find_triggered_run(
return run, run_id
-#
-# async def _record_distribution(
-# committee_name: str,
-# release: str,
-# platform: sql.DistributionPlatform,
-# namespace: str,
-# package: str,
-# version: str,
-# staging: bool,
-# ):
-# log.info("Creating distribution record")
-# dd = distribution.Data(
-# platform=platform,
-# owner_namespace=namespace,
-# package=package,
-# version=version,
-# details=False,
-# )
-# async with
storage.write_as_committee_member(committee_name=committee_name) as w:
-# try:
-# _dist, _added, _metadata = await
w.distributions.record_from_data(release=release, staging=staging, dd=dd)
-# except storage.AccessError as e:
-# _fail(f"Failed to record distribution: {e}")
-
-
async def _request_and_retry(
session: aiohttp.client.ClientSession,
url: str,
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 9b242d0a..7c60d88d 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -293,6 +293,24 @@ async def score_tool(args: ScoreArgs) -> results.Results |
None:
)
+def _extracted_dir(temp_dir: str) -> str | None:
+ # Loop through all the dirs in temp_dir
+ extract_dir = None
+ log.info(f"Checking directories in {temp_dir}: {os.listdir(temp_dir)}")
+ for dir_name in os.listdir(temp_dir):
+ if dir_name.startswith("."):
+ continue
+ dir_path = os.path.join(temp_dir, dir_name)
+ if os.path.isdir(dir_path):
+ if extract_dir is None:
+ extract_dir = dir_path
+ else:
+ return temp_dir
+ if extract_dir is None:
+ extract_dir = temp_dir
+ return extract_dir
+
+
async def _generate_cyclonedx_core(artifact_path: str, output_path: str) ->
dict[str, Any]:
"""Core logic to generate CycloneDX SBOM on failure."""
log.info(f"Generating CycloneDX SBOM for {artifact_path} -> {output_path}")
@@ -393,21 +411,3 @@ async def _generate_cyclonedx_core(artifact_path: str,
output_path: str) -> dict
except FileNotFoundError:
log.error("syft command not found. Is it installed and in PATH?")
raise SBOMGenerationError("syft command not found")
-
-
-def _extracted_dir(temp_dir: str) -> str | None:
- # Loop through all the dirs in temp_dir
- extract_dir = None
- log.info(f"Checking directories in {temp_dir}: {os.listdir(temp_dir)}")
- for dir_name in os.listdir(temp_dir):
- if dir_name.startswith("."):
- continue
- dir_path = os.path.join(temp_dir, dir_name)
- if os.path.isdir(dir_path):
- if extract_dir is None:
- extract_dir = dir_path
- else:
- return temp_dir
- if extract_dir is None:
- extract_dir = temp_dir
- return extract_dir
diff --git a/atr/template.py b/atr/template.py
index df194bf8..d71d5707 100644
--- a/atr/template.py
+++ b/atr/template.py
@@ -57,9 +57,6 @@ async def render_sync(
return await _render_in_thread(template, context_vars, app_instance)
-render = render_sync
-
-
async def _render_in_thread(template: jinja2.Template, context: dict, app:
app.Quart) -> str:
if template.environment.is_async is False:
raise RuntimeError("Template environment is not async")
@@ -77,3 +74,6 @@ async def _render_in_thread(template: jinja2.Template,
context: dict, app: app.Q
context=context,
)
return rendered_template
+
+
+render = render_sync
diff --git a/atr/validate.py b/atr/validate.py
index 575f2e5c..760dfc56 100644
--- a/atr/validate.py
+++ b/atr/validate.py
@@ -50,31 +50,50 @@ type ReleaseAnnotatedDivergences = Callable[[sql.Release],
AnnotatedDivergences]
T = TypeVar("T")
+if True:
-def committee(c: sql.Committee) -> AnnotatedDivergences:
- """Check that a committee is valid."""
+ def committee_components(
+ *components: str,
+ ) -> Callable[[CommitteeDivergences], CommitteeAnnotatedDivergences]:
+ """Wrap a Committee divergence generator to yield annotated
divergences."""
- yield from committee_child_committees(c)
- yield from committee_full_name(c)
+ def wrap(original: CommitteeDivergences) ->
CommitteeAnnotatedDivergences:
+ def replacement(c: sql.Committee) -> AnnotatedDivergences:
+ yield from divergences_with_annotations(
+ components,
+ original.__name__,
+ c.name,
+ original(c),
+ )
+ return replacement
-def committee_components(
- *components: str,
-) -> Callable[[CommitteeDivergences], CommitteeAnnotatedDivergences]:
- """Wrap a Committee divergence generator to yield annotated divergences."""
+ return wrap
- def wrap(original: CommitteeDivergences) -> CommitteeAnnotatedDivergences:
- def replacement(c: sql.Committee) -> AnnotatedDivergences:
- yield from divergences_with_annotations(
- components,
- original.__name__,
- c.name,
- original(c),
- )
+ def project_components(
+ *components: str,
+ ) -> Callable[[ProjectDivergences], ProjectAnnotatedDivergences]:
+ """Wrap a Project divergence generator to yield annotated
divergences."""
- return replacement
+ def wrap(original: ProjectDivergences) -> ProjectAnnotatedDivergences:
+ def replacement(p: sql.Project) -> AnnotatedDivergences:
+ yield from divergences_with_annotations(
+ components,
+ original.__name__,
+ p.name,
+ original(p),
+ )
- return wrap
+ return replacement
+
+ return wrap
+
+
+def committee(c: sql.Committee) -> AnnotatedDivergences:
+ """Check that a committee is valid."""
+
+ yield from committee_child_committees(c)
+ yield from committee_full_name(c)
@committee_components("Committee.child_committees")
@@ -176,25 +195,6 @@ def project(p: sql.Project) -> AnnotatedDivergences:
yield from project_release_policy(p)
-def project_components(
- *components: str,
-) -> Callable[[ProjectDivergences], ProjectAnnotatedDivergences]:
- """Wrap a Project divergence generator to yield annotated divergences."""
-
- def wrap(original: ProjectDivergences) -> ProjectAnnotatedDivergences:
- def replacement(p: sql.Project) -> AnnotatedDivergences:
- yield from divergences_with_annotations(
- components,
- original.__name__,
- p.name,
- original(p),
- )
-
- return replacement
-
- return wrap
-
-
@project_components("Project.category")
def project_category(p: sql.Project) -> Divergences:
"""Check that the category string uses 'label, label' syntax without
colons."""
diff --git a/scripts/fix_order.py b/scripts/fix_order.py
index 9774f35b..9c2c33c6 100755
--- a/scripts/fix_order.py
+++ b/scripts/fix_order.py
@@ -30,11 +30,13 @@ def main() -> None:
blocks = _parse_blocks(path.read_text(encoding="utf-8"))
nonfunc, func = [], []
seen_func = False
- main_guard: list[str] | None = None
- if blocks and _is_main_guard(blocks[-1][1]):
- main_guard = blocks[-1][1]
- blocks = blocks[:-1]
+ trailing: list[tuple[int, list[str]]] = []
+ while blocks and not _is_func(blocks[-1][1]):
+ trailing.insert(0, blocks.pop())
+ if not blocks:
+ blocks = trailing
+ trailing = []
for lineno, lines in blocks:
count = _count_defs(lines)
@@ -49,6 +51,14 @@ def main() -> None:
nonfunc.append((lineno, lines))
func.sort(key=_sort_key)
+ print(_assemble(nonfunc, func, trailing), end="")
+
+
+def _assemble(
+ nonfunc: list[tuple[int, list[str]]],
+ func: list[tuple[int, list[str]]],
+ trailing: list[tuple[int, list[str]]],
+) -> str:
nonfunc_text = "".join("".join(b) for _, b in nonfunc)
func_parts = [_normalise(b) for _, b in func]
func_text = "\n\n".join(func_parts)
@@ -56,12 +66,14 @@ def main() -> None:
output = nonfunc_text.rstrip("\n") + "\n\n\n" + func_text
else:
output = nonfunc_text + func_text
- if main_guard:
+ if trailing:
+ trailing_parts = [_normalise(b) for _, b in trailing]
+ trailing_text = "\n\n".join(trailing_parts)
if output:
- output = output.rstrip("\n") + "\n\n\n" + _normalise(main_guard)
+ output = output.rstrip("\n") + "\n\n\n" + trailing_text
else:
- output = _normalise(main_guard)
- print(output, end="")
+ output = trailing_text
+ return output
def _count_defs(block: list[str]) -> int:
@@ -79,13 +91,6 @@ def _is_func(block: list[str]) -> bool:
return False
-def _is_main_guard(block: list[str]) -> bool:
- for line in block:
- if line.strip():
- return line.startswith("if __name__")
- return False
-
-
def _normalise(block: list[str]) -> str:
while block and (not block[-1].strip()):
block = block[:-1]
diff --git a/scripts/fix_order.sh b/scripts/fix_order.sh
index b2bf2711..679faedc 100755
--- a/scripts/fix_order.sh
+++ b/scripts/fix_order.sh
@@ -12,8 +12,7 @@ tmp="$$.tmp.py"
backup="$HOME/.fix_order.backup.py"
script_dir="$(dirname "$0")"
-# TODO: Use uv here?
-python3 "$script_dir/fix_order.py" "$file" > "$tmp"
+uv run --frozen python3 "$script_dir/fix_order.py" "$file" > "$tmp"
status=$?
if [ $status -ne 0 ]
then
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]