You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/08/06 11:27:16 UTC
(tooling-trusted-releases) branch arm updated (c2b666f1 -> 36391d9f)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
from c2b666f1 Prevent "un-marking" catalog as reviewed.
new 72978462 Mark required fields required
new 36391d9f Support for disjunctive license choice in SBOM report
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
atr/form.py | 10 +++---
atr/get/sbom.py | 57 ++++++++++++++++----------------
atr/sbom/components.py | 16 ++++++---
atr/sbom/licenses.py | 68 ++++++++++++++++++++++++++------------
atr/sbom/models/components.py | 7 +++-
atr/sbom/models/licenses.py | 20 ++++++++++-
atr/shared/announce.py | 7 ++--
atr/shared/compose.py | 4 +--
atr/shared/distribution.py | 8 ++---
atr/shared/keys.py | 4 +++
atr/shared/manual.py | 8 +++--
atr/shared/projects.py | 13 +++++---
atr/shared/resolve.py | 8 +++--
atr/shared/start.py | 1 +
atr/shared/tokens.py | 4 +--
atr/shared/upload.py | 1 +
atr/shared/user.py | 2 +-
atr/shared/vote.py | 2 +-
atr/shared/voting.py | 6 ++--
tests/unit/test_sbom_components.py | 5 ++-
tests/unit/test_sbom_licenses.py | 30 +++++++++++++++++
21 files changed, 193 insertions(+), 88 deletions(-)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 02/02: Support for disjunctive license choice in SBOM report
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 36391d9fd6d9fa886234bbd6e6b71cd7fab7abda
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Aug 6 12:26:59 2026 +0100
Support for disjunctive license choice in SBOM report
---
atr/get/sbom.py | 57 ++++++++++++++++----------------
atr/sbom/components.py | 16 ++++++---
atr/sbom/licenses.py | 68 ++++++++++++++++++++++++++------------
atr/sbom/models/components.py | 7 +++-
atr/sbom/models/licenses.py | 20 ++++++++++-
tests/unit/test_sbom_components.py | 5 ++-
tests/unit/test_sbom_licenses.py | 30 +++++++++++++++++
7 files changed, 146 insertions(+), 57 deletions(-)
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index adf83522..f7addf92 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -71,7 +71,7 @@ async def quality(
if breakdown is None:
block.p["This SBOM could not be read, so its components cannot be shown."]
else:
- _components_section(block, breakdown, _license_categories(task))
+ _components_section(block, breakdown)
_license_section(block, task)
_vulnerability_section(block, task, task_result)
@@ -164,7 +164,6 @@ def _component_label(item: sbom.models.components.Item) -> str:
def _components_section(
block: htm.Block,
breakdown: sbom.models.components.Breakdown,
- license_categories: dict[str, sbom.models.licenses.Category],
) -> None:
if breakdown.subject is not None:
block.p["This SBOM describes ", htm.strong[_component_label(breakdown.subject)], "."]
@@ -184,20 +183,19 @@ def _components_section(
htm.span(".badge.bg-secondary.me-2.font-monospace")[str(len(group.items))],
htm.strong[group.component_type.capitalize()],
],
- _components_table(group.items, license_categories),
+ _components_table(group.items),
]
)
def _components_table(
items: list[sbom.models.components.Item],
- license_categories: dict[str, sbom.models.licenses.Category],
) -> htm.Element:
rows = [
htm.tr[
htm.td[item.name],
htm.td[item.version or "-"],
- htm.td[_licenses_cell(item.licenses, license_categories)],
+ htm.td[_licenses_cell(item.license_choices)],
htm.td[htm.code[item.purl] if item.purl else "-"],
]
for item in items
@@ -267,27 +265,32 @@ def _extract_vulnerability_severity(vuln: results.VulnerabilityDetails) -> str:
return "Unknown"
-def _license_badge(name: str, categories: dict[str, sbom.models.licenses.Category]) -> htm.Element:
- category = categories.get(name, sbom.models.licenses.Category.A)
- return htm.div(".d-flex.align-items-center")[
- htm.span(f".badge.me-2{_license_category_style(category)}")[str(category)],
+def _choice_badges(choice: sbom.models.licenses.Choice) -> list[htm.Element]:
+ # A single licence, or an AND that all applies, shows one badge for the whole expression. An OR
+ # ATR resolved shows the chosen half in its category colour and the halves it set aside drained
+ # of colour, so a reader sees what was on offer without the alternatives reading as a verdict
+ if choice.chosen is None:
+ return [_license_badge(choice.expression, choice.category)]
+ return [
+ _license_badge(choice.chosen, choice.category),
+ *(_license_alternative_badge(name, category) for name, category in choice.alternatives),
+ ]
+
+
+def _license_alternative_badge(name: str, category: sbom.models.licenses.Category) -> htm.Element:
+ # The category box keeps its letter but loses its colour, marking a licence ATR could have used
+ # but set aside in favour of a friendlier half of the same OR
+ return htm.div(".d-flex.align-items-center.text-muted")[
+ htm.span(".badge.me-2.bg-secondary.bg-opacity-25.text-dark")[str(category)],
name,
]
-def _license_categories(task: sql.Task | None) -> dict[str, sbom.models.licenses.Category]:
- # The score task already categorised every licence expression it found wanting, so a licence
- # absent from both lists is Category A - no need to run the classifier a second time here
- task_result = _score_result(task)
- if task_result is None:
- return {}
- categories: dict[str, sbom.models.licenses.Category] = {}
- for issues in (task_result.license_warnings, task_result.license_errors):
- if issues is None:
- continue
- for issue in _load_license_issues(issues):
- categories[issue.license_expression] = issue.category
- return categories
+def _license_badge(name: str, category: sbom.models.licenses.Category) -> htm.Element:
+ return htm.div(".d-flex.align-items-center")[
+ htm.span(f".badge.me-2{_license_category_style(category)}")[str(category)],
+ name,
+ ]
def _license_category_style(category: sbom.models.licenses.Category) -> str:
@@ -393,13 +396,11 @@ def _license_tally(
)
-def _licenses_cell(
- names: list[str],
- categories: dict[str, sbom.models.licenses.Category],
-) -> htm.Element | str:
- if not names:
+def _licenses_cell(choices: list[sbom.models.licenses.Choice]) -> htm.Element | str:
+ if not choices:
return "-"
- return htm.div(".d-flex.flex-column.gap-1")[*(_license_badge(name, categories) for name in names)]
+ badges = [badge for choice in choices for badge in _choice_badges(choice)]
+ return htm.div(".d-flex.flex-column.gap-1")[*badges]
def _load_license_issues(issues: list[str]) -> list[sbom.models.licenses.Issue]:
diff --git a/atr/sbom/components.py b/atr/sbom/components.py
index 8d5d8210..f2ae3182 100644
--- a/atr/sbom/components.py
+++ b/atr/sbom/components.py
@@ -19,6 +19,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
+from cyclonedx.model.license import LicenseExpression
+
if TYPE_CHECKING:
from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component
@@ -46,14 +48,20 @@ def _item(component: Component) -> models.components.Item:
return models.components.Item(
name=component.name or "unknown",
version=component.version,
- licenses=_licenses(component),
+ license_choices=_licenses(component),
purl=str(component.purl) if component.purl else None,
)
-def _licenses(component: Component) -> list[str]:
- values = {expr for choice in component.licenses if (expr := licenses.expression(choice))}
- return sorted(values)
+def _licenses(component: Component) -> list[models.licenses.Choice]:
+ # One reading per declared licence, deduplicated by expression and sorted so the table is stable
+ readings: dict[str, models.licenses.Choice] = {}
+ for choice in component.licenses:
+ expr = licenses.expression(choice)
+ if not expr:
+ continue
+ readings.setdefault(expr, licenses.assess(expr, isinstance(choice, LicenseExpression)))
+ return [readings[expr] for expr in sorted(readings)]
def _sort_key(item: models.components.Item) -> tuple[str, str]:
diff --git a/atr/sbom/licenses.py b/atr/sbom/licenses.py
index 92914944..e2bacf68 100644
--- a/atr/sbom/licenses.py
+++ b/atr/sbom/licenses.py
@@ -70,37 +70,21 @@ def check(
if not license_expr:
continue
- if isinstance(license_choice, LicenseExpression):
- try:
- atoms = license_expression_atoms(license_expr)
- except ValueError:
- atoms = {license_expr}
- else:
- atoms = {license_expr}
-
- categories = [_atom_category(atom) for atom in atoms]
- # "A OR B" lets the component be used under either half, so the friendliest half decides.
- # Anything else - an AND, parentheses, or a licence standing on its own - all applies at
- # once, so there the sternest half wins
- if isinstance(license_choice, LicenseExpression) and _is_simple_disjunction(license_expr):
- category = min(categories, key=_category_severity)
- else:
- category = max(categories, key=_category_severity)
-
- # A licence ATR cannot place reads as None here, and that is as concerning as Category X
+ reading = assess(license_expr, isinstance(license_choice, LicenseExpression))
issue = models.licenses.Issue(
component_name=name,
component_version=version,
license_expression=license_expr,
- category=category if (category is not None) else models.licenses.Category.X,
- any_unknown=(category is None),
+ category=reading.category,
+ any_unknown=reading.any_unknown,
+ chosen=reading.chosen,
scope=scope,
component_type=type,
)
- if category == models.licenses.Category.A:
+ if reading.category == models.licenses.Category.A:
if include_all:
good.append(issue)
- elif category == models.licenses.Category.B:
+ elif reading.category == models.licenses.Category.B:
# Category B may ship in a binary but not in source, so a source release makes it an error
(errors if is_source_release else warnings).append(issue)
else:
@@ -109,6 +93,40 @@ def check(
return good, warnings, errors
+def assess(license_expr: str, is_expression: bool) -> models.licenses.Choice:
+ # Read one declared licence. "A OR B" lets the component be used under either half, so the
+ # friendliest half decides and the rest are set aside as alternatives. Anything else - an AND,
+ # parentheses, or a licence standing on its own - all applies at once, so there the sternest
+ # half wins and there is no choice to offer
+ if is_expression:
+ try:
+ atoms = license_expression_atoms(license_expr)
+ except ValueError:
+ atoms = {license_expr}
+ else:
+ atoms = {license_expr}
+
+ ordered = sorted(atoms)
+ categories = {atom: _atom_category(atom) for atom in ordered}
+
+ if is_expression and _is_simple_disjunction(license_expr):
+ chosen = min(ordered, key=lambda atom: _category_severity(categories[atom]))
+ return models.licenses.Choice(
+ expression=license_expr,
+ category=_resolved_category(categories[chosen]),
+ any_unknown=(categories[chosen] is None),
+ chosen=chosen,
+ alternatives=[(atom, _resolved_category(categories[atom])) for atom in ordered if atom != chosen],
+ )
+
+ worst = max(categories.values(), key=_category_severity)
+ return models.licenses.Choice(
+ expression=license_expr,
+ category=_resolved_category(worst),
+ any_unknown=(worst is None),
+ )
+
+
def expression(license_choice: License) -> str | None:
# A licence is either an SPDX expression or a single licence, and a single licence carries a
# name of its own where it has no SPDX identifier to give
@@ -158,6 +176,12 @@ def _name_to_id() -> dict[str, str]:
return index
+def _resolved_category(category: models.licenses.Category | None) -> models.licenses.Category:
+ # A licence ATR cannot place reads as None, and that is as concerning as Category X, so X stands
+ # in for the unknown
+ return category if (category is not None) else models.licenses.Category.X
+
+
def _normalised_name(text: str) -> str:
words = []
for word in _NAME_SPLIT.split(text.casefold()):
diff --git a/atr/sbom/models/components.py b/atr/sbom/models/components.py
index eae0fe99..c5150424 100644
--- a/atr/sbom/models/components.py
+++ b/atr/sbom/models/components.py
@@ -17,15 +17,20 @@
from __future__ import annotations
+from typing import TYPE_CHECKING
+
import pydantic
from .base import Strict
+if TYPE_CHECKING:
+ from . import licenses
+
class Item(Strict):
name: str
version: str | None = None
- licenses: list[str] = pydantic.Field(default_factory=list)
+ license_choices: list[licenses.Choice] = pydantic.Field(default_factory=list)
purl: str | None = None
diff --git a/atr/sbom/models/licenses.py b/atr/sbom/models/licenses.py
index 18f23ed2..8182f892 100644
--- a/atr/sbom/models/licenses.py
+++ b/atr/sbom/models/licenses.py
@@ -34,6 +34,18 @@ class Category(enum.Enum):
return self.name
+class Choice(Strict):
+ # ATR's reading of one declared licence. Where an OR let the component be used under the
+ # friendliest of several licences, `chosen` names that half and `alternatives` holds the halves
+ # ATR set aside; for a single licence, or an AND that all applies at once, `chosen` is None and
+ # the whole `expression` carries the category
+ expression: str
+ category: Category
+ any_unknown: bool = False
+ chosen: str | None = None
+ alternatives: list[tuple[str, Category]] = pydantic.Field(default_factory=list)
+
+
class Issue(Strict):
component_name: str
component_version: str | None
@@ -41,6 +53,8 @@ class Issue(Strict):
license_expression: str
category: Category
any_unknown: bool = False
+ # The half of an OR ATR chose, where there was a choice. None where the whole expression applies
+ chosen: str | None = None
scope: str | None = None
@pydantic.field_validator("category", mode="before")
@@ -51,4 +65,8 @@ class Issue(Strict):
def __str__(self):
type_str = "Component" if (self.component_type is None) else self.component_type
version_str = f"@{self.component_version}" if (self.component_version != "UNKNOWN") else ""
- return f"{type_str} {self.component_name}{version_str} declares license {self.license_expression}"
+ licence = self.license_expression
+ if (self.chosen is not None) and (self.chosen != self.license_expression):
+ # An OR let ATR take the friendliest half, so name that half but keep the original on show
+ licence = f"{self.chosen} (chosen from {self.license_expression})"
+ return f"{type_str} {self.component_name}{version_str} declares license {licence}"
diff --git a/tests/unit/test_sbom_components.py b/tests/unit/test_sbom_components.py
index f4c11d4a..5591a5af 100644
--- a/tests/unit/test_sbom_components.py
+++ b/tests/unit/test_sbom_components.py
@@ -46,7 +46,10 @@ def test_breakdown() -> None:
("application", ["gamma"]),
("library", ["alpha", "beta"]),
]
- assert breakdown.groups[1].items[0].licenses == ["Apache-2.0"]
+ licence_choices = breakdown.groups[1].items[0].license_choices
+ assert [(choice.expression, str(choice.category), choice.chosen) for choice in licence_choices] == [
+ ("Apache-2.0", "A", None)
+ ]
# A BOM need not say what it is about
assert components.breakdown(sboms.with_components({"type": "library", "name": "alpha"})).subject is None
diff --git a/tests/unit/test_sbom_licenses.py b/tests/unit/test_sbom_licenses.py
index 8b96dc73..ab890f00 100644
--- a/tests/unit/test_sbom_licenses.py
+++ b/tests/unit/test_sbom_licenses.py
@@ -30,6 +30,23 @@ def test_expression() -> None:
assert licenses.expression(cdx_license.DisjunctiveLicense(name="Some bespoke licence")) == "Some bespoke licence"
+def test_assess_picks_the_friendliest_half_of_a_disjunction_and_sets_the_rest_aside() -> None:
+ reading = licenses.assess("Apache-2.0 OR GPL-3.0-only", is_expression=True)
+
+ assert (reading.category, reading.chosen) == (models.licenses.Category.A, "Apache-2.0")
+ # The half not taken is kept so the report can show what else was on offer
+ assert reading.alternatives == [("GPL-3.0-only", models.licenses.Category.X)]
+
+
+def test_assess_offers_no_choice_for_a_single_licence_or_a_conjunction() -> None:
+ single = licenses.assess("Apache-2.0", is_expression=False)
+ conjunction = licenses.assess("Apache-2.0 AND GPL-3.0-only", is_expression=True)
+
+ # A single licence, or an AND that all applies, carries the whole expression with no half to pick
+ assert (single.chosen, single.alternatives) == (None, [])
+ assert (conjunction.chosen, conjunction.category) == (None, models.licenses.Category.X)
+
+
def test_check() -> None:
good, warnings, errors = licenses.check(
sboms.with_components(
@@ -111,6 +128,19 @@ def test_check_a_disjunction_settles_on_the_friendliest_category() -> None:
assert errors == []
+def test_check_records_the_chosen_half_of_a_disjunction_it_still_flags() -> None:
+ # "B OR X" still warns, but only on the friendlier B half, and the report keeps the half it settled on
+ _good, warnings, _errors = licenses.check(
+ sboms.with_components(
+ {"type": "library", "name": "weak-or-forbidden", "licenses": [{"expression": "EPL-2.0 OR GPL-3.0-only"}]},
+ ),
+ )
+
+ assert [(issue.category, issue.chosen, issue.license_expression) for issue in warnings] == [
+ (models.licenses.Category.B, "EPL-2.0", "EPL-2.0 OR GPL-3.0-only"),
+ ]
+
+
def test_check_a_conjunction_settles_on_the_sternest_category() -> None:
# "A AND B" binds the component to both, so the sterner half decides
_good, _warnings, errors = licenses.check(
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/02: Mark required fields required
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 729784621fa07dd9677fb9ee406c999910dbdaac
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Aug 6 12:25:19 2026 +0100
Mark required fields required
---
atr/form.py | 10 +++++-----
atr/shared/announce.py | 7 ++++---
atr/shared/compose.py | 4 ++--
atr/shared/distribution.py | 8 ++++----
atr/shared/keys.py | 4 ++++
atr/shared/manual.py | 8 +++++---
atr/shared/projects.py | 13 ++++++++-----
atr/shared/resolve.py | 8 ++++++--
atr/shared/start.py | 1 +
atr/shared/tokens.py | 4 ++--
atr/shared/upload.py | 1 +
atr/shared/user.py | 2 +-
atr/shared/vote.py | 2 +-
atr/shared/voting.py | 6 +++---
14 files changed, 47 insertions(+), 31 deletions(-)
diff --git a/atr/form.py b/atr/form.py
index 0b496f4e..7e844133 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -186,8 +186,8 @@ def label(
if documentation is not None:
extra["documentation"] = documentation
extra["readonly"] = readonly
- # A field with no default is already marked required from is_required. This flag is for the fields
- # that carry an empty default - a list, an optional - but a validator still makes them mandatory
+ # Marks the field required in the UI. It has to be stated, since is_required over-fires on any field
+ # that simply lacks a default, whether or not the user must actually fill it in
if required:
extra["required"] = True
if len(kwargs) > 0:
@@ -938,9 +938,9 @@ def _render_row( # noqa: C901
label_classes = f"{label_col_class} col-form-label text-sm-end"
label_classes_with_error = f"{label_classes} text-danger" if has_flash_error else label_classes
label_content: list[Any] = [label_text]
- # A field with no default is required, but a collection type carries an empty default even when a
- # validator makes it mandatory, so `required=True` on the label covers what is_required can't see
- if is_required or _label_marks_required(field_info):
+ # Opt-in via required=True, not is_required: a bare field with no default reads as required to
+ # pydantic but still accepts empty input here, so is_required marks far more than is really mandatory
+ if _label_marks_required(field_info):
# The asterisk is hidden from screen readers, which pick the requirement up from the
# visually-hidden note beside it instead
label_content.append(htm.span(".text-danger.ms-1", aria_hidden="true")["*"])
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
index d7c655df..fb14c6fb 100644
--- a/atr/shared/announce.py
+++ b/atr/shared/announce.py
@@ -28,12 +28,12 @@ class AnnounceForm(form.Form):
"""Form for announcing a release preview."""
revision_number: safe.RevisionNumber = form.label("Revision number", widget=form.Widget.HIDDEN)
- email_to: str = form.label("To", widget=form.Widget.CUSTOM)
+ email_to: str = form.label("To", widget=form.Widget.CUSTOM, required=True)
email_cc: form.StrList = form.label("CC")
email_bcc: form.StrList = form.label("BCC")
- subject: str = form.label("Subject", widget=form.Widget.CUSTOM)
+ subject: str = form.label("Subject", widget=form.Widget.CUSTOM, required=True)
subject_template_hash: str = form.label("Subject template hash", widget=form.Widget.HIDDEN)
- body: str = form.label("Body", widget=form.Widget.CUSTOM, max_length=100_000)
+ body: str = form.label("Body", widget=form.Widget.CUSTOM, max_length=100_000, required=True)
download_path_suffix: safe.OptionalRelPath = form.label("Download path suffix", widget=form.Widget.CUSTOM)
download_page: form.OptionalURL = form.label(
"Download page URL",
@@ -56,6 +56,7 @@ class AnnounceForm(form.Form):
confirm_announce: Literal["CONFIRM"] = form.label(
"Confirm",
"Type CONFIRM (in capitals) to enable the submit button.",
+ required=True,
)
@pydantic.field_validator("download_page")
diff --git a/atr/shared/compose.py b/atr/shared/compose.py
index eb0daf72..0b1d652c 100644
--- a/atr/shared/compose.py
+++ b/atr/shared/compose.py
@@ -31,8 +31,8 @@ type MOVE_FILE = Literal["MOVE_FILE"]
class MoveFileForm(form.Form):
variant: MOVE_FILE = form.value(MOVE_FILE)
- source_files: form.RelPathList = form.label("Files to move")
- target_directory: safe.RelPath = form.label("Target directory")
+ source_files: form.RelPathList = form.label("Files to move", required=True)
+ target_directory: safe.RelPath = form.label("Target directory", required=True)
@pydantic.model_validator(mode="after")
def validate_move(self) -> "MoveFileForm":
diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py
index a87606cc..7d574bb6 100644
--- a/atr/shared/distribution.py
+++ b/atr/shared/distribution.py
@@ -157,8 +157,8 @@ class DistributionAutomateForm(form.Form):
"Owner or Namespace",
MAVEN_OWNER_NAMESPACE_HELP,
)
- package: safe.Alphanumeric = form.label("Package")
- version: safe.VersionKey = form.label("Version")
+ package: safe.Alphanumeric = form.label("Package", required=True)
+ version: safe.VersionKey = form.label("Version", required=True)
details: form.Bool = form.label(
"Include details",
"Include the details of the distribution in the response",
@@ -183,8 +183,8 @@ class DistributionRecordForm(form.Form):
"Owner or Namespace",
MAVEN_OWNER_NAMESPACE_HELP,
)
- package: safe.Alphanumeric = form.label("Package")
- version: safe.VersionKey = form.label("Version")
+ package: safe.Alphanumeric = form.label("Package", required=True)
+ version: safe.VersionKey = form.label("Version", required=True)
details: form.Bool = form.label(
"Include details",
"Include the details of the distribution in the response",
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index e4a7b5ae..aff71430 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -82,6 +82,7 @@ class AddSSHKeyForm(form.Form):
"Your SSH public key should be in the standard format, starting with a key type"
' (like "ssh-rsa" or "ssh-ed25519") followed by the key data.',
widget=form.Widget.TEXTAREA,
+ required=True,
)
@@ -126,12 +127,14 @@ class UploadFileForm(form.Form):
"Upload a KEYS file containing multiple PGP public keys."
" The file should contain keys in ASCII-armored format, starting with"
' "-----BEGIN PGP PUBLIC KEY BLOCK-----".',
+ required=True,
)
selected_committee: str = form.label(
"Associate keys with committee",
"Choose the committee whose KEYS file these keys belong to. Every key in the uploaded file is"
" added to that committee's public KEYS file, which is used to verify the signatures on its releases.",
widget=form.Widget.RADIO,
+ required=True,
)
@pydantic.model_validator(mode="after")
@@ -147,6 +150,7 @@ class UploadRemoteForm(form.Form):
"Committee",
"Choose the committee whose existing KEYS file to fetch from the ASF downloads server and import.",
widget=form.Widget.RADIO,
+ required=True,
)
diff --git a/atr/shared/manual.py b/atr/shared/manual.py
index fc1867d3..716864c6 100644
--- a/atr/shared/manual.py
+++ b/atr/shared/manual.py
@@ -24,9 +24,11 @@ import atr.models.safe as safe
class ResolveVoteForm(form.Form):
- vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote result", widget=form.Widget.RADIO)
- vote_thread_url: str = form.label("Vote thread URL")
- vote_result_url: str = form.label("Vote result URL")
+ vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label(
+ "Vote result", widget=form.Widget.RADIO, required=True
+ )
+ vote_thread_url: str = form.label("Vote thread URL", required=True)
+ vote_result_url: str = form.label("Vote result URL", required=True)
@pydantic.field_validator("vote_thread_url", "vote_result_url", mode="after")
@classmethod
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index d3fdbad7..99afffb7 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -122,11 +122,13 @@ class AddProjectForm(form.Form):
"Project name",
'For example, "Example" or "Example Components". Use title case; the "Apache" prefix is added for you.',
prefix="Apache",
+ required=True,
)
key: Annotated[safe.ProjectKey, pydantic.BeforeValidator(_strip_whitespace)] = form.label(
"Project key",
'For example, "example" or "example-components". '
"You must start with your committee key (above), and you must use lower case.",
+ required=True,
)
@pydantic.field_validator("display_name", mode="before")
@@ -295,8 +297,9 @@ class EditMetadataForm(form.Form):
"Project name",
'For example, "Example" or "Example Components". Use title case; the "Apache" prefix is added for you.',
prefix="Apache",
+ required=True,
)
- description: str = form.label(description="Project description", widget=form.Widget.TEXTAREA)
+ description: str = form.label(description="Project description", widget=form.Widget.TEXTAREA, required=True)
short_description: str = form.label(description="Short description", widget=form.Widget.TEXT, default="")
homepage: form.OptionalURL = form.label(
"Homepage",
@@ -557,7 +560,7 @@ class TrustedPublishingPolicyForm(form.Form):
class AddCategoryForm(form.Form):
variant: ADD_CATEGORY = form.value(ADD_CATEGORY)
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
- category_to_add: str = form.label("New category name")
+ category_to_add: str = form.label("New category name", required=True)
class RemoveCategoryForm(form.Form):
@@ -569,7 +572,7 @@ class RemoveCategoryForm(form.Form):
class AddLanguageForm(form.Form):
variant: ADD_LANGUAGE = form.value(ADD_LANGUAGE)
project_key: safe.ProjectKey = form.label("Project name", widget=form.Widget.HIDDEN)
- language_to_add: str = form.label("New language name")
+ language_to_add: str = form.label("New language name", required=True)
class RemoveLanguageForm(form.Form):
@@ -584,7 +587,7 @@ class ArchiveSelectedProject(form.Form):
class ArchiveSelectedRelease(form.Form):
variant: ARCHIVE_SELECTED_RELEASE = form.value(ARCHIVE_SELECTED_RELEASE)
- confirm_archive: Literal["ARCHIVE"] = form.label("Confirmation", "Type ARCHIVE to confirm.")
+ confirm_archive: Literal["ARCHIVE"] = form.label("Confirmation", "Type ARCHIVE to confirm.", required=True)
class CompleteApprovalRequest(form.Form):
@@ -593,7 +596,7 @@ class CompleteApprovalRequest(form.Form):
class ConfirmReleaseArchival(form.Form):
variant: CONFIRM_RELEASE_ARCHIVAL = form.value(CONFIRM_RELEASE_ARCHIVAL)
- confirm_archive: Literal["ARCHIVE"] = form.label("Confirmation", "Type ARCHIVE to confirm.")
+ confirm_archive: Literal["ARCHIVE"] = form.label("Confirmation", "Type ARCHIVE to confirm.", required=True)
class DeleteSelectedProject(form.Form):
diff --git a/atr/shared/resolve.py b/atr/shared/resolve.py
index 169ccc46..2929e919 100644
--- a/atr/shared/resolve.py
+++ b/atr/shared/resolve.py
@@ -49,10 +49,11 @@ class ResolveFormBase(form.Form):
class CancelSubmitForm(ResolveFormBase):
variant: CANCEL_SUBMIT = form.value(CANCEL_SUBMIT)
- email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA, max_length=100_000)
+ email_body: str = form.label("Email body", widget=form.Widget.TEXTAREA, max_length=100_000, required=True)
confirm_cancel: Literal["CONFIRM"] = form.label(
"Confirm",
"Type CONFIRM (in capitals) to cancel this vote.",
+ required=True,
)
vote_result: Literal["Cancelled"] = form.label("Vote result", default="Cancelled", widget=form.Widget.HIDDEN)
@@ -65,8 +66,11 @@ class SubmitForm(ResolveFormBase):
"{{OUTCOME}} is a placeholder - it will be replaced with the vote result you select when the email is sent.",
widget=form.Widget.TEXTAREA,
max_length=100_000,
+ required=True,
+ )
+ vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label(
+ "Vote result", widget=form.Widget.RADIO, required=True
)
- vote_result: Literal["Passed", "Failed", "Cancelled"] = form.label("Vote result", widget=form.Widget.RADIO)
automatic_resolve_when_finished: form.Bool = form.label(
"Automatically resolve the second round vote",
widget=form.Widget.CUSTOM,
diff --git a/atr/shared/start.py b/atr/shared/start.py
index ac865e54..0e2cc76c 100644
--- a/atr/shared/start.py
+++ b/atr/shared/start.py
@@ -50,6 +50,7 @@ class StartReleaseForm(form.Form):
" and you can also set your own tag before a vote starts."
" Release candidate drafts inactive for 90 days are cleaned up automatically,"
" with a warning email sent at 80 days.",
+ required=True,
)
auto_archive_prior: form.Bool = form.label(
"Auto archive prior release",
diff --git a/atr/shared/tokens.py b/atr/shared/tokens.py
index 6876d787..27251aed 100644
--- a/atr/shared/tokens.py
+++ b/atr/shared/tokens.py
@@ -32,7 +32,7 @@ type DELETE_TOKEN = Literal["delete_token"]
class AddTokenForm(form.Form):
variant: ADD_TOKEN = form.value(ADD_TOKEN)
- label: str = form.label("Label")
+ label: str = form.label("Label", required=True)
@pydantic.field_validator("label", mode="after")
@classmethod
@@ -51,7 +51,7 @@ class DeleteTokenForm(form.Form):
class IssueForm(form.Form):
- pat: str = form.label("PAT", widget=form.Widget.TEXT)
+ pat: str = form.label("PAT", widget=form.Widget.TEXT, required=True)
type TokenForm = Annotated[
diff --git a/atr/shared/upload.py b/atr/shared/upload.py
index 99a88a28..27bc94e0 100644
--- a/atr/shared/upload.py
+++ b/atr/shared/upload.py
@@ -45,6 +45,7 @@ class SvnImportForm(form.Form):
svn_path: safe.RelPath = form.label(
"SVN path",
"Path within the committee's svn:dist directory, e.g. 'java-library/4_0_4' or '3.1.5rc1'.",
+ required=True,
)
revision: str = form.label(
"Revision",
diff --git a/atr/shared/user.py b/atr/shared/user.py
index 0cb7f1fc..868c76a5 100644
--- a/atr/shared/user.py
+++ b/atr/shared/user.py
@@ -33,7 +33,7 @@ class DeleteCacheForm(form.Empty):
class TallyForm(form.Form):
- thread: str = form.label("Thread URL or ID")
+ thread: str = form.label("Thread URL or ID", required=True)
type UserCacheForm = Annotated[
diff --git a/atr/shared/vote.py b/atr/shared/vote.py
index 27c9df68..81ecab76 100644
--- a/atr/shared/vote.py
+++ b/atr/shared/vote.py
@@ -33,7 +33,7 @@ def message_id_source_archive_url(message_id: str, vote_recipient: str) -> str:
class CastVoteForm(form.Form):
- decision: Literal["+1", "0", "-1"] = form.label("Your vote", widget=form.Widget.CUSTOM)
+ decision: Literal["+1", "0", "-1"] = form.label("Your vote", widget=form.Widget.CUSTOM, required=True)
comment: str = form.label("Comment (optional)", widget=form.Widget.TEXTAREA, max_length=50_000)
vote_seq: form.OptionalInt = form.label("Vote serial", default=None, widget=form.Widget.HIDDEN)
vote_mode: sql.VoteMode = form.label("Vote mode", widget=form.Widget.HIDDEN)
diff --git a/atr/shared/voting.py b/atr/shared/voting.py
index a92a2e98..5b1bd66a 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -29,7 +29,7 @@ import atr.util as util
class StartVotingForm(form.Form):
- email_to: str = form.label("To (mailing list)", widget=form.Widget.CUSTOM)
+ email_to: str = form.label("To (mailing list)", widget=form.Widget.CUSTOM, required=True)
email_cc: form.StrList = form.label("CC")
email_bcc: form.StrList = form.label("BCC")
second_round_email_to: str | None = form.label(
@@ -42,9 +42,9 @@ class StartVotingForm(form.Form):
"Minimum number of hours the vote will be open for.",
default=72,
)
- subject: str = form.label("Subject", widget=form.Widget.CUSTOM)
+ subject: str = form.label("Subject", widget=form.Widget.CUSTOM, required=True)
subject_template_hash: str = form.label("Subject template hash", widget=form.Widget.HIDDEN)
- body: str = form.label("Body", widget=form.Widget.CUSTOM, max_length=100_000)
+ body: str = form.label("Body", widget=form.Widget.CUSTOM, max_length=100_000, required=True)
notify_when_finished: form.Bool = form.label(
"Email reminder",
widget=form.Widget.CUSTOM,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org