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