Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7c6cd96
Enhance app name validation to include additional checks for format a…
egarciamendez Jul 20, 2025
cb8d9ee
Enhance app name validation to include additional checks for format a…
egarciamendez Jul 20, 2025
e5727c7
Update app name validation tests to include cases for names starting …
egarciamendez Jul 21, 2025
f011119
Update validation message to clarify naming restrictions for Python p…
egarciamendez Jul 21, 2025
982d2ec
dot in app name is not allowed anymore
egarciamendez Jul 21, 2025
02509a5
Remove 'None' from invalid app name checks and add 'none' to constants
egarciamendez Jul 21, 2025
9d25586
Update config.py
egarciamendez Jul 22, 2025
925280f
ran towncrier by mistake
egarciamendez Jul 22, 2025
7ce356d
remove redudant test cases
egarciamendez Jul 22, 2025
615d177
remove redundant test cases
egarciamendez Jul 22, 2025
aebdaab
remove redundant test cases
egarciamendez Jul 22, 2025
43ee262
Add 'None' and 'none' to invalid app name test cases
egarciamendez Jul 22, 2025
aab7c2b
Update test case to use 'test.name' for app name canonicalization
egarciamendez Jul 22, 2025
cc36b4d
Replace dashes with underscores in canonicalized app name + remove ch…
egarciamendez Jul 24, 2025
22c50b7
Remove dashes replacement from canonicalized app name in convert.py
egarciamendez Jul 24, 2025
cea0991
Remove checks for starting with a number and period in app name valid…
egarciamendez Aug 5, 2025
37eae5e
Update app name validation message to clarify PEP508 compliance
egarciamendez Aug 5, 2025
820b0b5
`none` is daft but legal
egarciamendez Aug 5, 2025
fed1fe3
Enhance app name suggestion logic based on PEP508 compliance
egarciamendez Sep 8, 2025
bdb07a8
Refine app name validation to ensure compliance with Python identifie…
egarciamendez Sep 8, 2025
79851d8
Refactor PEP508 name validation by removing regex and updating test c…
egarciamendez Sep 8, 2025
418a77a
Add PEP508 compliant regex for module naming and create local setting…
egarciamendez Sep 8, 2025
4495ab4
Update test_is_valid_app_name.py
egarciamendez Sep 9, 2025
849bc65
Replace PEP508 regex with Android emulator name regex for validation
egarciamendez Sep 19, 2025
05f9444
Clarify PEP508 compliance requirements in application name guidelines
egarciamendez Sep 19, 2025
29d2aa9
Improve app name suggestion logic by using directory name directly if…
egarciamendez Sep 19, 2025
a4b4704
Remove invalid Unicode characters from app name validation tests
egarciamendez Sep 19, 2025
973bcc0
Enhance app name suggestion logic to use directory name directly when…
egarciamendez Sep 19, 2025
3957298
Enhance app name suggestion logic to use directory name directly when…
egarciamendez Sep 19, 2025
32e4277
Enhance app name suggestion logic to use directory name directly when…
egarciamendez Sep 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/2127.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Enhance app name validation to include additional checks for format and identifiers
38 changes: 28 additions & 10 deletions src/briefcase/commands/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,12 @@ def input_app_name(self, override_value: str | None) -> str:
"""
intro = (
"We need a name that can serve as a machine-readable Python package name for "
"your application. This name must be PEP508-compliant - that means the name "
"may only contain letters, numbers, hyphens and underscores; it can't contain "
"spaces or punctuation, and it can't start with a hyphen or underscore."
"your application. The name may only contain letters, numbers, hyphens and underscores; "
"it can't contain spaces or punctuation, and it can't start with a hyphen, underscore or a number."
)

default = "hello-world"
if (
"name" in self.pep621_data
and is_valid_app_name(self.pep621_data["name"])
and override_value is None
):
if "name" in self.pep621_data and override_value is None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why you've dropped the validity check here - 1hello.world is a valid project.name by PEP 508, which canonicalizes to 1hello-world - but that's not a valid app name, so it can't be used as a candidate.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dropped it because otherwise I wouldn't be able to pass this test:

Image

test.name from the [project] name in pyproject.toml isn’t a valid app name and therefore won’t be canonicalized to test-name.

To be honest, I’m a bit confused right now about what I should do instead.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's 3 cases here:

  1. The project name is useable as-is. foobar and foo-bar are both valid project names, and are also valid app names as-is. They can be directly provided as the candidate app name, as there's no transformation being applied.
  2. The project name could be a valid name, but requires some canonicalisation. test.name is a valid PEP 508 project name, and it canonicalizes to test-name; we don't want to provide that name as an automatic app name, but it's a reasonable candidate for an app name that the user could confirm as valid.
  3. The project name isn't valid, even after canonicalisation. 42foobar is a valid project name - but it canonicalizes to exactly the same value, which isn't a valid app name. In this case, we need to fall back to other hinted options (such as the base path of the project).

The code has a branch for 1 and 3; but the logic that triggers 1 is incorrect and it merges in the canonicalisation required by case 2. There's no logic handling the case of "test.name" being coerced into a hint for the user to confirm.

Does that make more sense?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thank you for the explanation (and your patience 😅)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok - but my the original point still stands: you've dropped the validation check on this branch. By this code, if user provides any value for name in the [project] data, the canonicalised version of that name will be used. You've added code for the three cases that I described - but this check comes before those cases are evaluated.

app_name = canonicalize_name(self.pep621_data["name"])
self.console.divider(title="App name")
self.console.prompt()
Expand All @@ -126,13 +121,36 @@ def input_app_name(self, override_value: str | None) -> str:
)
return app_name

if is_valid_app_name(self.base_path.name): # Directory name is normalised
default = canonicalize_name(self.base_path.name)
canonicalized_name = canonicalize_name(self.base_path.name)
# Case 1: Project name is usable as-is (e.g., "foobar", "foo-bar")
if (
is_valid_app_name(self.base_path.name)
and canonicalized_name == self.base_path.name
):
# Directory name is already valid - use it directly without prompting
if override_value is None:
self.console.divider(title="App name")
self.console.prompt()
self.console.prompt(
f"Using directory name as app name: {self.base_path.name!r}"
)
return self.base_path.name
# If override_value is provided, fall through to prompt with it
default = self.base_path.name
intro += (
"\n\n"
f"Based on your PEP508 formatted directory name, we suggest an "
f"app name of '{default}', but you can use another name if you want."
)
# Case 2: Project name could be valid after canonicalization (e.g., "test.name" -> "test-name", "test-app_name" -> "test-app-name")
elif is_valid_app_name(canonicalized_name):
default = canonicalized_name
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By this logic, case 1 and case 2 are functionally identical. The key difference between case 1 and case 2 is that we don't need to ask the user to confirm case 1 - we're using exactly the same name, so it's fine as is. Case 2 involves a transformation, so it needs to be confirmed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

intro += (
"\n\n"
f"Based on your directory name, we suggest an "
f"app name of '{default}', but you can use another name if you want."
)
# Case 3: Project name isn't valid, even after canonicalization - fall back to default

return self.console.text_question(
intro=intro,
Expand Down
47 changes: 30 additions & 17 deletions src/briefcase/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,37 @@
from .constants import RESERVED_WORDS
from .exceptions import BriefcaseConfigError

# PEP 508 restricts the naming of modules. The PEP defines a regex that uses
# re.IGNORECASE; but in in practice, packaging uses a version that rolls out the lower
# case, which has very slightly different semantics with non-ASCII characters. This
# definition is the one from
# https://github.com/pypa/packaging/blob/24.0/src/packaging/_tokenizer.py#L80
PEP508_NAME_RE = re.compile(r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9])$")


def is_valid_pep508_name(app_name):
"""Determine if the name is valid by PEP508 rules."""
return PEP508_NAME_RE.match(app_name)


def is_reserved_keyword(app_name):
"""Determine if the name is a reserved keyword."""
return keyword.iskeyword(app_name.lower()) or app_name.lower() in RESERVED_WORDS
return (
keyword.iskeyword(app_name.lower())
or app_name.lower() in RESERVED_WORDS
or keyword.iskeyword(app_name)
)


def is_valid_app_name(app_name):
return not is_reserved_keyword(app_name) and is_valid_pep508_name(app_name)
"""Determine if the app name is valid.

Checks the following:
- It is not a reserved keyword.
- It is a valid Python identifier when hyphens are replaced with underscores.
- It does not start with an underscore (reserved for internal use).
- It does not contain problematic Unicode characters that have edge-case behaviors.

:param app_name: The app name to validate.
:returns: True if the app name is valid; False otherwise.
"""
module_name = app_name.lower().replace("-", "_")

return all(
[
not is_reserved_keyword(app_name),
module_name.isidentifier(),
not module_name.startswith("_"),
]
)


def make_class_name(formal_name):
Expand Down Expand Up @@ -400,9 +411,11 @@ def __init__(
if not is_valid_app_name(self.app_name):
raise BriefcaseConfigError(
f"{self.app_name!r} is not a valid app name.\n\n"
"App names must not be reserved keywords such as 'and', 'for' and 'while'.\n"
"They must also be PEP508 compliant (i.e., they can only include letters,\n"
"numbers, '-' and '_'; must start with a letter; and cannot end with '-' or '_')."
"App names must:\n"
"- Not be reserved keywords (like 'and', 'for', 'while', 'main', 'test', etc.)\n"
"- Only contain letters, numbers, hyphens, and underscores\n"
"- Start with a letter (not a number, hyphen, or underscore)\n"
"- Be valid Python identifiers when hyphens are replaced with underscores"
)

if not is_valid_bundle_identifier(self.bundle):
Expand Down
8 changes: 6 additions & 2 deletions src/briefcase/integrations/android_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from datetime import datetime
from pathlib import Path

from briefcase.config import PEP508_NAME_RE
from briefcase.exceptions import (
BriefcaseCommandError,
IncompatibleToolError,
Expand All @@ -22,12 +21,17 @@
from briefcase.integrations.java import JDK
from briefcase.integrations.subprocess import SubprocessArgT

ANDROID_EMULATOR_NAME_RE = re.compile(
r"^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9])$"
)


DEVICE_NOT_FOUND = re.compile(r"^error: device '[^']*' not found")


def create_avd_validator(emulators):
def _validate_avd_name(avd):
if not PEP508_NAME_RE.match(avd):
if not ANDROID_EMULATOR_NAME_RE.match(avd):
raise ValueError(
"An emulator name may only contain letters, numbers, hyphens and underscores."
)
Expand Down
27 changes: 13 additions & 14 deletions tests/commands/convert/test_input_app_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,20 @@ def test_override_is_used(convert_command):


def test_no_pep621_data(convert_command, monkeypatch):
"""The app directory is used if there is no PEP621-name."""
mock_text_question = MagicMock()
monkeypatch.setattr(convert_command.console, "text_question", mock_text_question)
"""The app directory is used directly if there is no PEP621-name and the directory name is already valid."""
mock_divider = MagicMock()
mock_prompt = MagicMock()
monkeypatch.setattr(convert_command.console, "divider", mock_divider)
monkeypatch.setattr(convert_command.console, "prompt", mock_prompt)
convert_command.base_path /= "test-app-name"
convert_command.input_app_name(None)

mock_text_question.assert_called_once_with(
intro=PartialMatchString(
"Based on your PEP508 formatted directory name, we suggest an app name of 'test-app-name'"
),
description="App Name",
default="test-app-name",
validator=convert_command.validate_app_name,
override_value=None,
)
result = convert_command.input_app_name(None)

assert result == "test-app-name"
mock_divider.assert_called_once_with(title="App name")
assert mock_prompt.call_count == 2
mock_prompt.assert_any_call()
mock_prompt.assert_any_call("Using directory name as app name: 'test-app-name'")


def test_valid_pep621_app_name(convert_command):
Expand Down Expand Up @@ -72,7 +71,7 @@ def test_hint_is_canonicalized(convert_command, monkeypatch):

mock_text_question.assert_called_once_with(
intro=PartialMatchString(
"Based on your PEP508 formatted directory name, we suggest an app name of 'test-app-name'"
"Based on your directory name, we suggest an app name of 'test-app-name'"
),
description="App Name",
default="test-app-name",
Expand Down
2 changes: 1 addition & 1 deletion tests/commands/new/test_validate_app_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"helloworld",
"helloWorld",
"hello42world",
"42helloworld", # ?? Are we sure this is correct?
"hello_world",
"hello-world",
],
Expand All @@ -24,6 +23,7 @@ def test_valid_app_name(new_command, name):
"helloworld!", # contains punctuation
"_helloworld", # leading underscore
"-helloworld", # leading hyphen
"42helloworld", # starting with a number
"pass", # python keyword
"Pass", # python keyword, but different case usage
"PASS", # python keyword, but all upper case
Expand Down
4 changes: 2 additions & 2 deletions tests/config/test_AppConfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ def test_extra_attrs():
"my_app", # contains underscore
"myapp2", # ends with digit
"my2app", # contains digit
"myApp-", # ends with hyphen (converts to valid identifier)
"myApp_", # ends with underscore (valid Python identifier)
],
)
def test_valid_app_name(name):
Expand All @@ -204,9 +206,7 @@ def test_valid_app_name(name):
"myapp!", # end punctuation
"my$app", # other punctuation
"-myApp", # initial hyphen
"myApp-", # end hyphen
"_myApp", # initial underscore
"myApp_", # end underscore
],
)
def test_invalid_app_name(name):
Expand Down
34 changes: 27 additions & 7 deletions tests/config/test_is_valid_app_name.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@
"helloworld",
"helloWorld",
"hello42world",
"42helloworld",
"hello_world",
"hello-world",
"helloworld42",
"a", # single letter
"abc123", # alphanumeric
"none", # `None` is illegal, but when lower case, it isn't a reserved word
"helloworld_", # ends with underscore (valid Python identifier)
"helloworld-", # ends with hyphen (converts to valid identifier)
],
)
def test_is_valid_app_name(name):
Expand All @@ -22,10 +27,12 @@ def test_is_valid_app_name(name):
@pytest.mark.parametrize(
"name",
[
# Invalid characters
"hello world",
"helloworld!",
"_helloworld",
"-helloworld",
# python reserved words
"switch",
"pass",
"false",
Expand All @@ -34,12 +41,25 @@ def test_is_valid_app_name(name):
"main",
"socket",
"test",
# ı, İ and K (i.e. 0x212a) are valid ASCII when made lowercase and as such are
# accepted by the official PEP 508 regex... but they are rejected here to ensure
# compliance with the regex that is used in practice.
"helloworld_ı",
"İstanbul",
"Kelvin",
"None",
# Additional invalid formats
"my$app", # dollar sign
"app@domain", # at symbol
"app.name", # period
"2app", # starts with number
"42app", # starts with number
"_", # single underscore
"-", # single hyphen
"1", # single digit
"123", # all digits
# Case variations of reserved words
"Switch",
"SWITCH",
"FALSE",
"Pass",
"PASS",
"Test",
"TEST",
],
)
def test_is_invalid_app_name(name):
Expand Down
Loading