You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ak...@apache.org on 2026/05/21 19:05:29 UTC
(tooling-trusted-releases) 01/01: Make project display name editable from the metadata tab
This is an automated email from the ASF dual-hosted git repository.
andrewmusselman pushed a commit to branch project-display-name-1254
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit a5fbebb25189a6d5eb6c6126bfa7ade8959a440b
Author: Andrew K. Musselman <ak...@apache.org>
AuthorDate: Thu May 21 12:05:03 2026 -0700
Make project display name editable from the metadata tab
Adds display_name as a field on EditMetadataForm alongside the existing
description, homepage, etc. Extracts the validation rules from
AddProjectForm into a shared _validate_display_name helper so the two
forms stay in lockstep. Updates the edit_metadata writer to apply the
new value to project.name, and the metadata form renderer to
pre-populate the field from project.name.
UI: the 'Apache' prefix is now rendered as a non-editable Bootstrap
input-group addon next to the text field. Users see Apache | [Example
Components], type only the suffix, and can't accidentally delete or
change the prefix. Done via a new 'prefix' parameter on form.label
that wraps the Widget.TEXT render in an input-group when set. Applied
to both AddProjectForm and EditMetadataForm so create and edit feel
the same.
Validator is forgiving about the prefix: users who type 'Apache' out
of habit (any case — 'apache', 'APACHE', 'ApAcHe') get it stripped
and the canonical 'Apache ' reapplied. Bare 'Apache' alone is rejected
with 'Name must have at least two words.', and an empty submission is
rejected with 'Name is required.'
Also fixes #1256: the validator was rejecting real Apache projects
with hyphens or underscores in their names (Apache Empire-db, Apache
mod_jk, Apache mod_perl, etc). The previous final 'whole string must
be alphanumeric (modulo a few punctuation chars)' check was redundant
with the per-word case check above it and incorrectly rejected the
allowed_irregular_words entries. Per-word check is sufficient because
every regex restricts to alphanumerics, so any string that passes is
already character-safe by construction.
Audited the validator against every TLP in the Whimsy registry. Two
real Apache project names that previously failed are now covered by
additions to allowed_irregular_words: 'Lucene.Net' (Whimsy capitalises
it differently to the existing 'Lucene.NET' allow-list entry; added
both 'Lucene.Net' and 'Lucene.net' to cover all variants) and
'(APR)' (for 'Apache Portable Runtime (APR)').
Adds tests/unit/test_shared_projects.py covering full-name input,
suffix-only input (the new shape from the input-group UI), explicit
Apache passthrough in any case, whitespace normalisation, empty and
bare-Apache rejection, the regression cases from #1256, and the
allow-list additions.
Fixes #1254
Fixes #1256
---
atr/form.py | 11 +++-
atr/get/projects.py | 1 +
atr/shared/projects.py | 107 ++++++++++++++++++++----------
atr/storage/writers/project.py | 1 +
tests/unit/test_shared_projects.py | 129 +++++++++++++++++++++++++++++++++++++
5 files changed, 214 insertions(+), 35 deletions(-)
diff --git a/atr/form.py b/atr/form.py
index 95c9e3ce..5aa93428 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -1125,7 +1125,16 @@ def _render_widget( # noqa: C901
attrs = {**base_attrs, "type": "text"}
if field_value:
attrs["value"] = str(field_value)
- widget = htpy.input(**attrs)
+ text_input = htpy.input(**attrs)
+ json_schema_extra = field_info.json_schema_extra or {}
+ field_prefix = json_schema_extra.get("prefix") if isinstance(json_schema_extra, dict) else None
+ if field_prefix:
+ widget = htm.div(".input-group")[
+ htm.span(".input-group-text")[str(field_prefix)],
+ text_input,
+ ]
+ else:
+ widget = text_input
case Widget.TEXTAREA:
json_schema_extra = field_info.json_schema_extra or {}
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 77db91e4..ae971a3b 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -719,6 +719,7 @@ async def _render_metadata_form(project: sql.Project) -> htm.Element:
submit_label="Save",
defaults={
"project_key": str(project.key),
+ "display_name": (project.name or "").removeprefix("Apache "),
"description": project.description or "",
"short_description": project.short_description or "",
"homepage": project.homepage or "",
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 50919351..9d8b7fc3 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -45,13 +45,73 @@ def _strip_whitespace(value: object) -> object:
return value.strip() if isinstance(value, str) else value
+def _validate_display_name(val: str) -> str:
+ """Apply the Apache project display name rules. Returns the normalised value.
+
+ The form's "Apache" prefix is added automatically, so callers may submit
+ either "Example Components" or "Apache Example Components" — both yield
+ "Apache Example Components". The leading "Apache" is stripped
+ case-insensitively, so "apache example" and "APACHE Example" also work.
+ Raises ValueError on failure. Shared between AddProjectForm and
+ EditMetadataForm so the two stay in lockstep.
+ """
+ display_name = val.strip()
+ # Normalise spaces in the display name
+ display_name = re.sub(r" +", " ", display_name)
+
+ if not display_name:
+ raise ValueError("Name is required.")
+
+ # Strip a leading "Apache" (any case) so users who type the prefix out of
+ # habit aren't punished. The canonical "Apache " is reapplied below.
+ leading_apache = re.match(r"^[Aa][Pp][Aa][Cc][Hh][Ee](?:\s+|$)", display_name)
+ if leading_apache:
+ display_name = display_name[leading_apache.end() :]
+
+ if not display_name:
+ raise ValueError("Name must have at least two words.")
+
+ display_name = f"Apache {display_name}"
+
+ display_name_words = display_name.split(" ")
+
+ # Validate display name uses correct case. Each non-Apache word must either
+ # be on the irregular-word allow-list or match one of the structural regexes.
+ # This check is sufficient: every regex restricts to alphanumerics, so any
+ # string that passes here is already character-safe by construction.
+ allowed_irregular_words = {
+ ".NET",
+ "(APR)",
+ "C++",
+ "Empire-db",
+ "Lucene.NET",
+ "Lucene.Net",
+ "Lucene.net",
+ "for",
+ "jclouds",
+ }
+ r_pascal_case = re.compile(r"^([A-Z][0-9a-z]*)+$")
+ r_camel_case = re.compile(r"^[a-z]*([A-Z][0-9a-z]*)+$")
+ r_mod_case = re.compile(r"^mod(_[0-9a-z]+)+$")
+ for display_name_word in display_name_words[1:]:
+ if display_name_word in allowed_irregular_words:
+ continue
+ is_pascal_case = r_pascal_case.match(display_name_word)
+ is_camel_case = r_camel_case.match(display_name_word)
+ is_mod_case = r_mod_case.match(display_name_word)
+ if not (is_pascal_case or is_camel_case or is_mod_case):
+ raise ValueError(f"Name word {display_name_word!r} must be in PascalCase, camelCase, or mod_ case.")
+
+ return display_name
+
+
class AddProjectForm(form.Form):
committee_key: safe.CommitteeKey = form.label("Committee key", widget=form.Widget.HIDDEN)
committee_key_display: str = form.label(description="Committee key", widget=form.Widget.STATIC, default="")
display_name: str = form.label(
"Project name",
- 'For example, "Apache Example" or "Apache Example Components". '
- 'You must start with "Apache " and you must use title case.',
+ 'For example, "Example" or "Example Components". Use title case; the "Apache" prefix is added for you.',
+ prefix="Apache",
)
key: Annotated[safe.ProjectKey, pydantic.BeforeValidator(_strip_whitespace)] = form.label(
"Project key",
@@ -62,38 +122,7 @@ class AddProjectForm(form.Form):
@pydantic.field_validator("display_name", mode="before")
@classmethod
def validate_display_name(cls, val: str) -> str:
- display_name = val.strip()
- # Normalise spaces in the display name
- display_name = re.sub(r" +", " ", display_name)
-
- display_name_words = display_name.split(" ")
-
- # Validate display name starts with "Apache"
- if display_name_words[0] != "Apache":
- raise ValueError("The first word in the name must be 'Apache'.")
- # Validate display name has at least two words
- if not display_name_words[1:]:
- raise ValueError("Name must have at least two words.")
-
- # Validate display name uses correct case
- allowed_irregular_words = {".NET", "C++", "Empire-db", "Lucene.NET", "for", "jclouds"}
- r_pascal_case = re.compile(r"^([A-Z][0-9a-z]*)+$")
- r_camel_case = re.compile(r"^[a-z]*([A-Z][0-9a-z]*)+$")
- r_mod_case = re.compile(r"^mod(_[0-9a-z]+)+$")
- for display_name_word in display_name_words[1:]:
- if display_name_word in allowed_irregular_words:
- continue
- is_pascal_case = r_pascal_case.match(display_name_word)
- is_camel_case = r_camel_case.match(display_name_word)
- is_mod_case = r_mod_case.match(display_name_word)
- if not (is_pascal_case or is_camel_case or is_mod_case):
- raise ValueError("Name words must be in PascalCase, camelCase, or mod_ case.")
-
- # Validate display name is alphanumeric with spaces, dots, and plus signs
- if not display_name.replace(" ", "").replace(".", "").replace("+", "").isalnum():
- raise ValueError("Name must be alphanumeric and may include spaces or dots or plus signs.")
-
- return display_name
+ return _validate_display_name(val)
@pydantic.field_validator("key", mode="after")
@classmethod
@@ -205,6 +234,11 @@ class EditCycleDatesForm(form.Form):
class EditMetadataForm(form.Form):
variant: EDIT_METADATA = form.value(EDIT_METADATA)
project_key: safe.ProjectKey = form.label("Project key", widget=form.Widget.HIDDEN)
+ display_name: str = form.label(
+ "Project name",
+ 'For example, "Example" or "Example Components". Use title case; the "Apache" prefix is added for you.',
+ prefix="Apache",
+ )
description: str = form.label(description="Project description", widget=form.Widget.TEXTAREA)
short_description: str = form.label(description="Short description", widget=form.Widget.TEXT)
homepage: form.OptionalURL = form.label(
@@ -240,6 +274,11 @@ class EditMetadataForm(form.Form):
rows=3,
)
+ @pydantic.field_validator("display_name", mode="before")
+ @classmethod
+ def validate_display_name(cls, val: str) -> str:
+ return _validate_display_name(val)
+
class EditVersionSchemeForm(form.Form):
variant: EDIT_VERSION_SCHEME = form.value(EDIT_VERSION_SCHEME)
diff --git a/atr/storage/writers/project.py b/atr/storage/writers/project.py
index 51a3f3a2..871a5aa0 100644
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -285,6 +285,7 @@ class CommitteeMember(CommitteeParticipant):
if not project:
raise storage.AccessError(f"Project '{form.project_key}' not found.", status=404)
+ project.name = form.display_name
project.homepage = str(form.homepage) if form.homepage else None
project.lifecycle_page = str(form.lifecycle_page) if form.lifecycle_page else None
project.download_page = str(form.download_page) if form.download_page else None
diff --git a/tests/unit/test_shared_projects.py b/tests/unit/test_shared_projects.py
new file mode 100644
index 00000000..142bbc22
--- /dev/null
+++ b/tests/unit/test_shared_projects.py
@@ -0,0 +1,129 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import pytest
+
+import atr.shared.projects as shared_projects
+
+
+@pytest.mark.parametrize(
+ "name",
+ [
+ # Baselines — full names.
+ "Apache Example",
+ "Apache Example Components",
+ "Apache Commons Codec",
+ "Apache Maven",
+ "Apache HTTP",
+ # Suffix-only — the form's UI submits this shape after the prefix
+ # addon makes "Apache" non-editable.
+ "Example",
+ "Example Components",
+ "Commons Codec",
+ "Maven",
+ "HTTP",
+ # Whitespace normalisation.
+ " Apache Example ",
+ " Example Components ",
+ # Allowed irregular words.
+ "Apache Lucene.NET",
+ "Apache C++",
+ "Apache jclouds",
+ "Apache .NET",
+ ".NET",
+ "Apache CouchDB for Erlang",
+ "CouchDB for Erlang",
+ # Regression cases from #1256: real Apache projects with `-` or `_`.
+ "Apache Empire-db",
+ "Empire-db",
+ "Apache mod_jk",
+ "mod_jk",
+ "Apache mod_perl",
+ "Apache mod_python",
+ "Apache mod_ftp",
+ "Apache mod_jk Connector",
+ # Whimsy spelling variants and parenthesized abbreviations.
+ "Apache Lucene.Net",
+ "Lucene.Net",
+ "Lucene.net",
+ "Apache Portable Runtime (APR)",
+ "Portable Runtime (APR)",
+ ],
+)
+def test_validate_display_name_accepts(name: str) -> None:
+ # Should not raise.
+ shared_projects._validate_display_name(name)
+
+
+@pytest.mark.parametrize(
+ "name",
+ [
+ "", # empty
+ " ", # whitespace only
+ "lowercase", # word not in any case class
+ "Apache lowercase", # same, with explicit prefix
+ "123Numbers", # starts with a digit
+ "With|Pipe", # disallowed character
+ "Has Slash/Char", # disallowed character
+ "Has<Tag>", # disallowed character
+ ],
+)
+def test_validate_display_name_rejects(name: str) -> None:
+ with pytest.raises(ValueError):
+ shared_projects._validate_display_name(name)
+
+
+def test_validate_display_name_returns_normalised_value() -> None:
+ assert shared_projects._validate_display_name(" Apache Example ") == "Apache Example"
+
+
+def test_validate_display_name_prepends_apache_when_omitted() -> None:
+ assert shared_projects._validate_display_name("Example") == "Apache Example"
+ assert shared_projects._validate_display_name("Example Components") == "Apache Example Components"
+
+
+def test_validate_display_name_accepts_explicit_prefix() -> None:
+ # Users who type "Apache" by habit shouldn't be punished.
+ assert shared_projects._validate_display_name("Apache Example") == "Apache Example"
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "apache Example",
+ "APACHE Example",
+ "ApAcHe Example",
+ "apache Example", # extra whitespace between Apache and the rest
+ " apache Example ",
+ ],
+)
+def test_validate_display_name_strips_apache_case_insensitively(raw: str) -> None:
+ assert shared_projects._validate_display_name(raw) == "Apache Example"
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "Apache",
+ "apache",
+ "APACHE",
+ "Apache ", # trailing space, nothing after
+ ],
+)
+def test_validate_display_name_rejects_bare_apache(raw: str) -> None:
+ with pytest.raises(ValueError):
+ shared_projects._validate_display_name(raw)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org