This is an automated email from the ASF dual-hosted git repository. arm pushed a commit to branch arm in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 8c7de3418e343fc8352fcaa8622e3401712c3ed2 Author: Alastair McFarlane <[email protected]> AuthorDate: Fri Apr 3 13:30:21 2026 +0100 #1010 - move distribution web validation to work on API calls as well, and support field-level errors in model_validator functions. --- atr/api/__init__.py | 4 ++++ atr/form.py | 18 +++++++++++++++--- atr/models/safe.py | 5 +++++ atr/shared/distribution.py | 20 ++++---------------- atr/util.py | 11 +++++++++++ 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/atr/api/__init__.py b/atr/api/__init__.py index 9cca2570..71756c40 100644 --- a/atr/api/__init__.py +++ b/atr/api/__init__.py @@ -323,6 +323,7 @@ async def distribution_record( Record a manual distribution. """ asf_uid = _jwt_asf_uid() + util.validate_distribution_owner_namespace(data.platform, data.distribution_owner_namespace) async with db.session() as db_data: release = await db_data.release( project_key=str(data.project), @@ -364,6 +365,7 @@ async def distribution_record_from_workflow( Record the result of an automated distribution from the GH tooling-actions workflow. Validates the caller is a Github workflow, triggered by ATR itself """ + _payload, _asf_uid, _project, release = await interaction.trusted_jwt_for_dist( data.publisher, data.jwt, @@ -372,6 +374,7 @@ async def distribution_record_from_workflow( data.project, data.version, ) + util.validate_distribution_owner_namespace(data.platform, data.distribution_owner_namespace) # TODO: Split the below code into a new function and reuse in /publisher and /distribution / record. if release.committee is None: raise exceptions.NotFound(f"Release {release.key} has no committee") @@ -838,6 +841,7 @@ async def publisher_distribution_record( data.jwt, interaction.TrustedProjectPhase.COMPOSE, ) + util.validate_distribution_owner_namespace(data.platform, data.distribution_owner_namespace) async with db.session() as db_data: release = await db_data.release( project_key=project.key, diff --git a/atr/form.py b/atr/form.py index f84a7e41..507e12bf 100644 --- a/atr/form.py +++ b/atr/form.py @@ -99,12 +99,24 @@ def flash_error_data( for i, error in enumerate(errors): loc = error["loc"] - kind = error["type"] msg = error["msg"] - msg = msg.replace(": An email address", " because an email address") - msg = msg.replace("Value error, ", "") + kind = error["type"] original = error["input"] field_name, field_label = name_and_label(concrete_cls, i, loc) + if not loc: + ctx_error = error.get("ctx", {}).get("error") + # If we have a field name in a ValueError, make sure we get the right message and original value + # This comes from raising a `ValueError` in a `pydantic.model_validator` + if isinstance(ctx_error, ValueError) and len(ctx_error.args) >= 2 and isinstance(ctx_error.args[1], str): + loc = (ctx_error.args[1],) + msg = ctx_error.args[0] + # Now reconstruct the field_name and label + field_name, field_label = name_and_label(concrete_cls, i, loc) + if isinstance(original, dict): + original = original[field_name] + msg = msg.replace(": An email address", " because an email address") + msg = msg.replace("Value error, ", "") + flash_data[field_name] = { "label": field_label, "original": json_suitable(original), diff --git a/atr/models/safe.py b/atr/models/safe.py index 61ba28f5..7996be87 100644 --- a/atr/models/safe.py +++ b/atr/models/safe.py @@ -186,6 +186,11 @@ def _strip_slashes_or_none(v: object) -> object: return v +type OptionalAlphanumeric = Annotated[ + Alphanumeric | None, + pydantic.BeforeValidator(_strip_slashes_or_none), +] + type OptionalRelPath = Annotated[ RelPath | None, pydantic.BeforeValidator(_strip_slashes_or_none), diff --git a/atr/shared/distribution.py b/atr/shared/distribution.py index cc7d41bf..f8046a3d 100644 --- a/atr/shared/distribution.py +++ b/atr/shared/distribution.py @@ -126,7 +126,7 @@ class DistributionAutomateForm(form.Form): platform: form.Enum[DistributionPlatform] = form.label( "Platform", widget=form.Widget.SELECT, enum_filter_include=[DistributionPlatform.MAVEN.value] ) - owner_namespace: safe.Alphanumeric = form.label( + owner_namespace: safe.OptionalAlphanumeric = form.label( "Owner or Namespace", "Who owns or names the package (Maven groupId, npm @scope, Docker namespace, " "GitHub owner, ArtifactHub repo). Leave blank if not used.", @@ -140,26 +140,20 @@ class DistributionAutomateForm(form.Form): @pydantic.model_validator(mode="after") def validate_owner_namespace(self) -> DistributionAutomateForm: - platform_name: str = self.platform.name # type: ignore[attr-defined] sql_platform = self.platform.to_sql() # type: ignore[attr-defined] default_owner_namespace = sql_platform.value.default_owner_namespace - requires_owner_namespace = sql_platform.value.requires_owner_namespace if default_owner_namespace and (not self.owner_namespace): self.owner_namespace = default_owner_namespace - if requires_owner_namespace and (not self.owner_namespace): - raise ValueError(f'Platform "{platform_name}" requires an owner or namespace.') - - if (not requires_owner_namespace) and (not default_owner_namespace) and self.owner_namespace: - raise ValueError(f'Platform "{platform_name}" does not require an owner or namespace.') + util.validate_distribution_owner_namespace(sql_platform, self.owner_namespace) return self class DistributionRecordForm(form.Form): platform: form.Enum[DistributionPlatform] = form.label("Platform", widget=form.Widget.SELECT) - owner_namespace: safe.Alphanumeric = form.label( + owner_namespace: safe.OptionalAlphanumeric = form.label( "Owner or Namespace", "Who owns or names the package (Maven groupId, npm @scope, Docker namespace, " "GitHub owner, ArtifactHub repo). Leave blank if not used.", @@ -173,19 +167,13 @@ class DistributionRecordForm(form.Form): @pydantic.model_validator(mode="after") def validate_owner_namespace(self) -> DistributionRecordForm: - platform_name: str = self.platform.name # type: ignore[attr-defined] sql_platform = self.platform.to_sql() # type: ignore[attr-defined] default_owner_namespace = sql_platform.value.default_owner_namespace - requires_owner_namespace = sql_platform.value.requires_owner_namespace if default_owner_namespace and (not self.owner_namespace): self.owner_namespace = default_owner_namespace - if requires_owner_namespace and (not self.owner_namespace): - raise ValueError(f'Platform "{platform_name}" requires an owner or namespace.') - - if (not requires_owner_namespace) and (not default_owner_namespace) and self.owner_namespace: - raise ValueError(f'Platform "{platform_name}" does not require an owner or namespace.') + util.validate_distribution_owner_namespace(sql_platform, self.owner_namespace) return self diff --git a/atr/util.py b/atr/util.py index b716a98c..e8088cea 100644 --- a/atr/util.py +++ b/atr/util.py @@ -1154,6 +1154,17 @@ def validate_as_type[T](value: Any, t: type[T]) -> T: return value +def validate_distribution_owner_namespace(platform: sql.DistributionPlatform, namespace: Any): + default_owner_namespace = platform.value.default_owner_namespace + requires_owner_namespace = platform.value.requires_owner_namespace + + if requires_owner_namespace and (not namespace): + raise ValueError(f'Platform "{platform.value.name}" requires an owner or namespace.', "owner_namespace") + + if (not requires_owner_namespace) and (not default_owner_namespace) and namespace: + raise ValueError(f'Platform "{platform.value.name}" does not require an owner or namespace.', "owner_namespace") + + def validate_email_recipients(recipients: EmailRecipients) -> None: if not recipients.email_to: raise ValueError("At least one To recipient is required") --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
